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 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 } diff --git a/packages/react/.storybook/preview.jsx b/packages/react/.storybook/preview.jsx index 890987353b8..c1a7ed1103d 100644 --- a/packages/react/.storybook/preview.jsx +++ b/packages/react/.storybook/preview.jsx @@ -55,7 +55,7 @@ const preview = { [ '*', // Within a set of stories, set the order to the following - ['*', 'Playground', /Playground$/, 'Features', 'Examples'], + ['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'], ], ], ], @@ -72,7 +72,7 @@ const preview = { [ '*', // Within a set of stories, set the order to the following - ['*', 'Playground', /Playground$/, 'Features', 'Examples'], + ['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'], ], ], ], @@ -92,7 +92,7 @@ const preview = { [ '*', // Within a set of stories, set the order to the following - ['*', 'Playground', /Playground$/, 'Features', 'Examples'], + ['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'], ], ], ], @@ -110,7 +110,7 @@ const preview = { [ '*', // Within a set of stories, set the order to the following - ['*', 'Playground', /Playground$/, 'Features', 'Examples'], + ['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'], ], ], ], 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", ] `; diff --git a/packages/react/src/experimental/Tabs/README.mdx b/packages/react/src/experimental/Tabs/README.mdx new file mode 100644 index 00000000000..9bfdebc1314 --- /dev/null +++ b/packages/react/src/experimental/Tabs/README.mdx @@ -0,0 +1,65 @@ +import {Canvas, Meta} from '@storybook/addon-docs/blocks' + +import * as TabsStories from './Tabs.stories' +import * as TabsExamples from './Tabs.examples.stories' + + + +# Tabs + +The `Tabs` component is a headless component that provides the logic and state management for building tabbed interfaces. It allows users to switch between different views or sections of content within the same context. + +The primary responsibility of the `Tabs` component is to manage the active tab state, handle keyboard navigation, and ensure accessibility compliance. It does not include any styling or visual representation, allowing developers to customize the appearance according to their design requirements. + + + +## Using `Tabs` + +To use the `Tabs` component, you need to import it along with its associated utility hooks: `useTabList`, `useTab`, and `useTabPanel`. These hooks help generate the props needed to create the necessary elements for the tabbed interface. + +Simply call these hooks and spread the returned props onto the elements of your choosing. + +```tsx +import React from 'react' +import {Tabs, useTabList, useTab, useTabPanel} from '@primer/react/experimental' + +function TabPanel({children, ...props}) { + const tabPanelProps = useTabPanel(props) + return
{children}
+} + +function Tab({children, ...props}) { + const tabProps = useTab(props) + return +} + +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/experimental/Tabs/Tabs.examples.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx new file mode 100644 index 00000000000..df2d7a518e4 --- /dev/null +++ b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx @@ -0,0 +1,84 @@ +import type {Meta} from '@storybook/react-vite' +import {action} from 'storybook/actions' +import React from 'react' +import {Tabs, TabPanel, useTabList, useTab} from './Tabs' +import {ActionList} from '../../ActionList' +import Flash from '../../Flash' + +const meta = { + title: 'Experimental/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; disabled?: boolean}>) => { + const {tabProps} = useTab({value: props.value, disabled: props.disabled}) + + 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 + + Four + + + Panel one + Panel two + Panel three + Panel four + +
+ + + ) +} 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/experimental/Tabs/Tabs.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.stories.tsx new file mode 100644 index 00000000000..eb3f001e53b --- /dev/null +++ b/packages/react/src/experimental/Tabs/Tabs.stories.tsx @@ -0,0 +1,25 @@ +import type {Meta} from '@storybook/react-vite' +import React from 'react' +import {Tabs, TabList, Tab, TabPanel} from './Tabs' + +const meta = { + title: 'Experimental/Components/Tabs', + component: Tabs, +} satisfies Meta + +export default meta + +export const Default = () => { + return ( + + + One + Two + Three + + Panel one + Panel two + Panel three + + ) +} diff --git a/packages/react/src/experimental/Tabs/Tabs.test.tsx b/packages/react/src/experimental/Tabs/Tabs.test.tsx new file mode 100644 index 00000000000..b6e2ac35897 --- /dev/null +++ b/packages/react/src/experimental/Tabs/Tabs.test.tsx @@ -0,0 +1,570 @@ +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 './Tabs' + +describe('Tabs', () => { + test('`defaultValue` sets the default selected tab', () => { + 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('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() + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + expect(tabA).toHaveAttribute('aria-selected', 'true') + }) + + 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 Tabs', + }), + ).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 act(async () => { + 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'}) + + await act(async () => { + 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'}) + + await act(async () => { + 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'}) + + await act(async () => { + 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'}) + + await act(async () => { + 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'}) + + await act(async () => { + 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'}) + await act(async () => { + 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'}) + + await act(async () => { + 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'}) + + await act(async () => { + 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 () => { + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + act(() => { + fireEvent.focus(tabB) + }) + + 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'}) + + await act(async () => { + tabA.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(tabC).toHaveFocus() + }) + + test('clicking disabled tab does not select it', async () => { + 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'}) + + // Verify the disabled tab has aria-disabled attribute + expect(tabB).toHaveAttribute('aria-disabled', 'true') + + await act(() => { + fireEvent.click(tabB) + }) + + // The selected tab should remain the same (tabA) + 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') + }) +}) diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx new file mode 100644 index 00000000000..71fe02c0e4b --- /dev/null +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -0,0 +1,364 @@ +import React, { + createContext, + useContext, + useId, + useMemo, + type AriaAttributes, + type ElementRef, + type PropsWithChildren, +} from 'react' +import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' +import {useControllableState} from '../../hooks/useControllableState' +import {useProvidedRefOrCreate} from '../../hooks' + +/** + * Props to be used when the Tabs component's state is controlled by the parent + */ +type ControlledTabsProps = { + /** + * Specify the selected tab + */ + 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 +} + +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({ + name: 'tab-selection', + defaultValue: props.defaultValue ?? props.value, + value: props.value, + }) + + const savedOnValueChange = React.useRef(onValueChange) + const contextValue: TabsContextValue = useMemo(() => { + return { + groupId, + selectedValue, + selectTab(value: string) { + setSelectedValue(value) + savedOnValueChange.current?.({value}) + }, + } + }, [groupId, selectedValue, setSelectedValue]) + + 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 useTabList( + props: TabListProps & { + /** Optional ref to use for the tablist. If none is provided, one will be generated automatically */ + ref?: React.RefObject + }, +): { + /** 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 + + const ref = useProvidedRefOrCreate(props.ref) + + const onKeyDown = (event: React.KeyboardEvent) => { + const {current: tablist} = ref + if (tablist === null) { + return + } + + 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' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (selectedTabIndex + 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === prevKey) { + 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') { + if (tabs[0]) { + tabs[0].focus() + } + } else if (event.key === 'End') { + if (tabs.length > 0) { + tabs[tabs.length - 1].focus() + } + } + } + + return { + tabListProps: { + 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([aria-disabled])')) +} + +type TabProps = React.ComponentPropsWithoutRef<'button'> & { + /** + * Specify whether the tab is disabled + */ + disabled?: boolean + + /** + * Provide a value that uniquely identifies 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`. + */ +function useTab( + props: Pick, +): { + /** Props to be spread onto the tab component */ + tabProps: 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 { + tabProps: { + 'aria-disabled': disabled ? true : undefined, + 'aria-controls': panelId, + '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 = { + /** + * Provide a value that uniquely identifies the tab panel. This should mirror + * the value set for the corresponding tab + */ + value: string +} + +/** 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 aid 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}` + const tabId = `${tabs.groupId}-tab-${value}` + + return { + tabPanelProps: { + 'aria-labelledby': tabId, + 'data-selected': tabs.selectedValue === value ? '' : undefined, + id, + hidden: tabs.selectedValue !== value, + role: 'tabpanel', + }, + } +} + +function TabPanel({children, value, ...rest}: React.HTMLAttributes & 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, 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'