diff --git a/docs/package.json b/docs/package.json index 1ba2f9ab12b..a56010d297b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -48,6 +48,7 @@ "react-error-boundary": "6.1.1", "react-hook-form": "^7.73.1", "react-is": "^19.2.5", + "react-router": "7.14.2", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", diff --git a/docs/scripts/reportBrokenLinks.mts b/docs/scripts/reportBrokenLinks.mts index 48c1cb89506..24fb660af37 100644 --- a/docs/scripts/reportBrokenLinks.mts +++ b/docs/scripts/reportBrokenLinks.mts @@ -8,6 +8,13 @@ async function main() { ignoredPaths: [], // CSS selectors for content to ignore during link checking ignoredContent: [], + ignores: [ + { + // React Router demo for Tabs component + path: '/react/components/tabs', + href: ['/overview', '/projects', '/account'], + }, + ], }); process.exit(issues.length); diff --git a/docs/src/app/(docs)/react/components/page.mdx b/docs/src/app/(docs)/react/components/page.mdx index d8165f073f2..c900c059d43 100644 --- a/docs/src/app/(docs)/react/components/page.mdx +++ b/docs/src/app/(docs)/react/components/page.mdx @@ -1698,6 +1698,7 @@ A component for toggling between related panels on the same page. - Root - List - Tab + - LinkTab - Indicator - Panel - Exports: @@ -1717,7 +1718,10 @@ A component for toggling between related panels on the same page. - Tabs - Tab - Props: className, disabled, nativeButton, render, style, value - Data Attributes: data-activation-direction, data-active, data-disabled, data-orientation -- Types: Tabs.Indicator.Props, Tabs.Indicator.State, Tabs.List.Props, Tabs.List.State, Tabs.Panel.Metadata, Tabs.Panel.Props, Tabs.Panel.State, Tabs.Root.ChangeEventDetails, Tabs.Root.ChangeEventReason, Tabs.Root.Orientation, Tabs.Root.Props, Tabs.Root.State, Tabs.Tab.ActivationDirection, Tabs.Tab.Metadata, Tabs.Tab.Position, Tabs.Tab.Props, Tabs.Tab.Size, Tabs.Tab.State, Tabs.Tab.Value + - Tabs - LinkTab + - Props: className, disabled, render, style, value + - Data Attributes: data-activation-direction, data-active, data-disabled, data-orientation +- Types: Tabs.Indicator.Props, Tabs.Indicator.State, Tabs.LinkTab.Props, Tabs.LinkTab.State, Tabs.List.Props, Tabs.List.State, Tabs.Panel.Metadata, Tabs.Panel.Props, Tabs.Panel.State, Tabs.Root.ChangeEventDetails, Tabs.Root.ChangeEventReason, Tabs.Root.Orientation, Tabs.Root.Props, Tabs.Root.State, Tabs.Tab.ActivationDirection, Tabs.Tab.Metadata, Tabs.Tab.Position, Tabs.Tab.Props, Tabs.Tab.Size, Tabs.Tab.State, Tabs.Tab.Value diff --git a/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.module.css b/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.module.css new file mode 100644 index 00000000000..8b0d58871f6 --- /dev/null +++ b/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.module.css @@ -0,0 +1,121 @@ +.Tabs { + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; +} + +.Route { + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--color-gray-200); + padding: 0.5rem 0.75rem; + color: var(--color-gray-500); + font-size: 0.8125rem; + line-height: 1rem; +} + +.RouteLabel { + white-space: nowrap; +} + +.RouteValue { + border-radius: 0.25rem; + background-color: var(--color-gray-100); + color: var(--color-gray-900); + font-family: var(--font-mono); + padding: 0.125rem 0.25rem; +} + +.List { + display: flex; + position: relative; + z-index: 0; + padding-inline: 0.25rem; + gap: 0.25rem; + box-shadow: inset 0 -1px var(--color-gray-200); +} + +.LinkTab { + display: flex; + align-items: center; + justify-content: center; + outline: 0; + color: var(--color-gray-600); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 400; + text-decoration: none; + user-select: none; + white-space: nowrap; + word-break: keep-all; + padding-inline: 0.5rem; + padding-block: 0; + height: 2rem; + + &[data-active] { + color: var(--color-gray-900); + } + + @media (hover: hover) { + &:hover { + color: var(--color-gray-900); + } + } + + &:focus-visible { + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0.25rem 0; + border-radius: 0.25rem; + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } + } +} + +.Indicator { + position: absolute; + z-index: -1; + left: 0; + top: 50%; + translate: var(--active-tab-left) -50%; + width: var(--active-tab-width); + height: 1.5rem; + border-radius: 0.25rem; + background-color: var(--color-gray-100); + transition-property: translate, width; + transition-duration: 200ms; + transition-timing-function: ease-in-out; +} + +.Panel { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + min-height: 8rem; + outline: 0; + padding: 1.5rem; + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + border-radius: 0.375rem; + } + + &[hidden] { + display: none; + } +} + +.PanelText { + margin: 0; + max-width: 20rem; + color: var(--color-gray-700); + font-size: 0.9375rem; + line-height: 1.5rem; +} diff --git a/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.tsx b/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.tsx new file mode 100644 index 00000000000..b6fab9195a9 --- /dev/null +++ b/docs/src/app/(docs)/react/components/tabs/demos/links/css-modules/index.tsx @@ -0,0 +1,63 @@ +'use client'; +import * as React from 'react'; +import { Link, MemoryRouter, useLocation } from 'react-router'; +import { Tabs } from '@base-ui/react/tabs'; +import styles from './index.module.css'; + +const routes = [ + { + path: '/overview', + label: 'Overview', + description: 'Review the latest activity and key project updates.', + }, + { + path: '/projects', + label: 'Projects', + description: 'Track milestones, assignments, and project health.', + }, + { + path: '/account', + label: 'Account', + description: 'Manage profile details, permissions, and preferences.', + }, +] as const; + +export default function ExampleTabsLinks() { + return ( + + + + ); +} + +function RouterTabs() { + const location = useLocation(); + const activeRoute = routes.find((route) => route.path === location.pathname) ?? routes[0]; + + return ( + +
+ Current route + {location.pathname} +
+ + {routes.map((route) => ( + } + value={route.path} + > + {route.label} + + ))} + + + {routes.map((route) => ( + +

{route.description}

+
+ ))} +
+ ); +} diff --git a/docs/src/app/(docs)/react/components/tabs/demos/links/index.ts b/docs/src/app/(docs)/react/components/tabs/demos/links/index.ts new file mode 100644 index 00000000000..79108e2e57d --- /dev/null +++ b/docs/src/app/(docs)/react/components/tabs/demos/links/index.ts @@ -0,0 +1,5 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoTabsLinks = createDemoWithVariants(import.meta.url, { CssModules, Tailwind }); diff --git a/docs/src/app/(docs)/react/components/tabs/demos/links/tailwind/index.tsx b/docs/src/app/(docs)/react/components/tabs/demos/links/tailwind/index.tsx new file mode 100644 index 00000000000..172f0f23641 --- /dev/null +++ b/docs/src/app/(docs)/react/components/tabs/demos/links/tailwind/index.tsx @@ -0,0 +1,90 @@ +'use client'; +import * as React from 'react'; +import { Link, MemoryRouter, useLocation } from 'react-router'; +import { Tabs } from '@base-ui/react/tabs'; + +const routes = [ + { + path: '/overview', + label: 'Overview', + description: 'Review the latest activity and key project updates.', + }, + { + path: '/projects', + label: 'Projects', + description: 'Track milestones, assignments, and project health.', + }, + { + path: '/account', + label: 'Account', + description: 'Manage profile details, permissions, and preferences.', + }, +] as const; + +const linkTabClassName = ` + flex h-8 items-center justify-center px-2 + text-sm font-normal break-keep whitespace-nowrap text-gray-600 no-underline + outline-hidden select-none + before:inset-x-0 before:inset-y-1 before:rounded-xs + before:-outline-offset-1 before:outline-blue-800 + hover:text-gray-900 data-[active]:text-gray-900 + focus-visible:relative focus-visible:before:absolute + focus-visible:before:outline focus-visible:before:outline-2 +`; + +const indicatorClassName = ` + absolute top-1/2 left-0 z-[-1] + h-6 w-[var(--active-tab-width)] rounded-xs bg-gray-100 + translate-x-[var(--active-tab-left)] -translate-y-1/2 + transition-all duration-200 ease-in-out +`; + +const panelClassName = ` + relative flex min-h-32 items-center p-6 + -outline-offset-1 outline-blue-800 + focus-visible:rounded-md focus-visible:outline-2 +`; + +export default function ExampleTabsLinks() { + return ( + + + + ); +} + +function RouterTabs() { + const location = useLocation(); + const activeRoute = routes.find((route) => route.path === location.pathname) ?? routes[0]; + + return ( + +
+ Current route + + {location.pathname} + +
+ + {routes.map((route) => ( + } + value={route.path} + > + {route.label} + + ))} + + + {routes.map((route) => ( + +

+ {route.description} +

+
+ ))} +
+ ); +} diff --git a/docs/src/app/(docs)/react/components/tabs/page.mdx b/docs/src/app/(docs)/react/components/tabs/page.mdx index 8dd9e469acf..342946dfdd3 100644 --- a/docs/src/app/(docs)/react/components/tabs/page.mdx +++ b/docs/src/app/(docs)/react/components/tabs/page.mdx @@ -8,6 +8,7 @@ /> import { DemoTabsHero } from './demos/hero'; +import { DemoTabsLinks } from './demos/links'; @@ -31,23 +32,33 @@ import { Tabs } from '@base-ui/react/tabs'; ### Links -Use the `render` prop and set `nativeButton={false}` on `` to render tabs as anchor elements. +Use the `` part to render tabs as anchor elements. -```jsx title="Tabs as links" + + +When rendering with a router link component (such as React Router or Next.js `Link`), control the `value` prop on `` from the current route. +Router links intercept clicks to navigate client-side, so the URL should remain the source of truth. +Deriving `value` from the URL keeps the indicator in sync with clicks, browser back/forward, redirects, and route changes that happen outside the tab list. + +```jsx title="Router-driven tabs" +import { Link, useLocation } from 'react-router'; import { Tabs } from '@base-ui/react/tabs'; -import Link from 'next/link'; - - - {/* @highlight-start */} - {/* @highlight-text "nativeButton={false}" "render" */} - } value="overview"> - Overview - - {/* @highlight-end */} - - {/* ... */} -; +function NavTabs() { + const location = useLocation(); + return ( + + + }> + Overview + + }> + Projects + + + + ); +} ``` ## API reference @@ -66,6 +77,10 @@ import { TypesTabs } from './types'; +### LinkTab + + + ### Indicator diff --git a/docs/src/app/(docs)/react/components/tabs/types.md b/docs/src/app/(docs)/react/components/tabs/types.md index 336a6dbd1aa..2529173db39 100644 --- a/docs/src/app/(docs)/react/components/tabs/types.md +++ b/docs/src/app/(docs)/react/components/tabs/types.md @@ -258,6 +258,8 @@ type TabsTabState = { active: boolean; /** The component orientation. */ orientation: Tabs.Root.Orientation; + /** The direction used for tab activation. */ + tabActivationDirection: Tabs.Tab.ActivationDirection; }; ``` @@ -295,14 +297,58 @@ type TabsTabPosition = { left: number; right: number; top: number; bottom: numbe type TabsTabSize = { width: number; height: number }; ``` +### LinkTab + +An individual interactive tab that navigates to a different page or section. +Renders an `` element. + +**LinkTab Props:** + +| Prop | Type | Default | Description | +| :-------- | :----------------------------------------------------------------------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| value\* | `Tabs.Tab.Value` | - | The value of the LinkTab. | +| disabled | `boolean` | - | Whether the LinkTab is disabled. If the first LinkTab in a `` is disabled, it won't initially be selected. Instead, the next enabled LinkTab will be selected. However, it does not work like this during server-side rendering, as it is not known during pre-rendering which LinkTabs are disabled. To work around it, ensure that `defaultValue` or `value` on `` is set to an enabled LinkTab's value. | +| className | `string \| ((state: Tabs.LinkTab.State) => string \| undefined)` | - | CSS class applied to the element, or a function that returns a class based on the component's state. | +| style | `React.CSSProperties \| ((state: Tabs.LinkTab.State) => React.CSSProperties \| undefined)` | - | Style applied to the element, or a function that returns a style object based on the component's state. | +| render | `ReactElement \| ((props: HTMLProps, state: Tabs.LinkTab.State) => ReactElement)` | - | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. | + +**LinkTab Data Attributes:** + +| Attribute | Type | Description | +| :------------------------ | :---------------------------------------------- | :---------------------------------------------------------------------------- | +| data-orientation | `'horizontal' \| 'vertical'` | Indicates the orientation of the tabs. | +| data-disabled | - | Present when the tab is disabled. | +| data-activation-direction | `'left' \| 'right' \| 'up' \| 'down' \| 'none'` | Indicates the direction of the activation (based on the previous active tab). | +| data-active | - | Present when the tab is active. | + +### LinkTab.Props + +Re-export of [LinkTab](#linktab) props. + +### LinkTab.State + +```typescript +type TabsLinkTabState = { + /** Whether the component should ignore user interaction. */ + disabled: boolean; + /** Whether the component is active. */ + active: boolean; + /** The component orientation. */ + orientation: Tabs.Root.Orientation; + /** The direction used for tab activation. */ + tabActivationDirection: Tabs.Tab.ActivationDirection; +}; +``` + ## Export Groups - `Tabs.Root`: `Tabs.Root`, `Tabs.Root.State`, `Tabs.Root.Props`, `Tabs.Root.Orientation`, `Tabs.Root.ChangeEventReason`, `Tabs.Root.ChangeEventDetails` - `Tabs.Tab`: `Tabs.Tab`, `Tabs.Tab.Value`, `Tabs.Tab.ActivationDirection`, `Tabs.Tab.Position`, `Tabs.Tab.Size`, `Tabs.Tab.Metadata`, `Tabs.Tab.State`, `Tabs.Tab.Props` +- `Tabs.LinkTab`: `Tabs.LinkTab`, `Tabs.LinkTab.State`, `Tabs.LinkTab.Props` - `Tabs.Indicator`: `Tabs.Indicator`, `Tabs.Indicator.State`, `Tabs.Indicator.Props` - `Tabs.Panel`: `Tabs.Panel`, `Tabs.Panel.Metadata`, `Tabs.Panel.State`, `Tabs.Panel.Props` - `Tabs.List`: `Tabs.List`, `Tabs.List.State`, `Tabs.List.Props` -- `Default`: `TabsRootOrientation`, `TabsRootState`, `TabsRootProps`, `TabsRootChangeEventReason`, `TabsRootChangeEventDetails`, `TabsIndicatorState`, `TabsIndicatorProps`, `TabsTabValue`, `TabsTabActivationDirection`, `TabsTabPosition`, `TabsTabSize`, `TabsTabMetadata`, `TabsTabState`, `TabsTabProps`, `TabsPanelMetadata`, `TabsPanelState`, `TabsPanelProps`, `TabsListState`, `TabsListProps` +- `Default`: `TabsRootOrientation`, `TabsRootState`, `TabsRootProps`, `TabsRootChangeEventReason`, `TabsRootChangeEventDetails`, `TabsIndicatorState`, `TabsIndicatorProps`, `TabsTabValue`, `TabsTabActivationDirection`, `TabsTabPosition`, `TabsTabSize`, `TabsTabMetadata`, `TabsTabState`, `TabsTabProps`, `TabsLinkTabState`, `TabsLinkTabProps`, `TabsPanelMetadata`, `TabsPanelState`, `TabsPanelProps`, `TabsListState`, `TabsListProps` ## Canonical Types @@ -320,6 +366,8 @@ Maps `Canonical`: `Alias` — Use Canonical when its namespace is already import - `Tabs.Tab.Metadata`: `TabsTabMetadata` - `Tabs.Tab.State`: `TabsTabState` - `Tabs.Tab.Props`: `TabsTabProps` +- `Tabs.LinkTab.State`: `TabsLinkTabState` +- `Tabs.LinkTab.Props`: `TabsLinkTabProps` - `Tabs.Indicator.State`: `TabsIndicatorState` - `Tabs.Indicator.Props`: `TabsIndicatorProps` - `Tabs.Panel.Metadata`: `TabsPanelMetadata` diff --git a/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.module.css b/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.module.css new file mode 100644 index 00000000000..a6019b47b62 --- /dev/null +++ b/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.module.css @@ -0,0 +1,175 @@ +.root { + display: grid; + gap: 1rem; + width: min(42rem, 100%); +} + +.header { + display: grid; + gap: 0.375rem; +} + +.title { + margin: 0; + color: var(--color-gray-900); + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600; +} + +.description { + margin: 0; + color: var(--color-gray-600); + font-size: 0.875rem; + line-height: 1.5rem; +} + +.description code { + border-radius: 0.25rem; + background-color: var(--color-gray-100); + color: var(--color-gray-900); + font-size: 0.8125rem; + padding-inline: 0.25rem; +} + +.tabs { + overflow: hidden; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: canvas; +} + +.locationBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--color-gray-200); + background-color: var(--color-gray-50); + padding: 0.75rem 1rem; +} + +.locationLabel { + color: var(--color-gray-600); + font-size: 0.8125rem; + line-height: 1.25rem; + font-weight: 500; +} + +.locationValue { + border-radius: 0.25rem; + background-color: var(--color-gray-100); + color: var(--color-gray-900); + font-size: 0.8125rem; + line-height: 1.25rem; + padding-inline: 0.375rem; +} + +.list { + display: flex; + position: relative; + z-index: 0; + gap: 0.25rem; + padding: 0.25rem; + box-shadow: inset 0 -1px var(--color-gray-200); +} + +.linkTab { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + outline: 0; + color: var(--color-gray-600); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + text-decoration: none; + user-select: none; + white-space: nowrap; + padding-inline: 0.75rem; + height: 2rem; + + &[data-active] { + color: var(--color-gray-900); + } + + @media (hover: hover) { + &:hover { + color: var(--color-gray-900); + } + } + + &:focus-visible { + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 0.25rem; + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } + } +} + +.indicator { + position: absolute; + z-index: -1; + top: var(--active-tab-top); + right: var(--active-tab-right); + bottom: var(--active-tab-bottom); + left: var(--active-tab-left); + border-radius: 0.25rem; + background-color: var(--color-gray-100); + transition-property: left, right, top, bottom; + transition-duration: 200ms; + transition-timing-function: ease-in-out; +} + +.panel { + outline: 0; + padding: 1rem; + + &[data-hidden] { + display: none; + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.panelContent { + display: grid; + gap: 0.75rem; + min-height: 11rem; + align-content: start; +} + +.kicker { + margin: 0; + color: var(--color-gray-500); + font-size: 0.8125rem; + line-height: 1.25rem; + font-weight: 500; +} + +.panelTitle { + margin: 0; + color: var(--color-gray-900); + font-size: 1.125rem; + line-height: 1.5rem; + font-weight: 600; +} + +.panelDescription { + max-width: 32rem; + margin: 0; + color: var(--color-gray-600); + font-size: 0.9375rem; + line-height: 1.5rem; +} diff --git a/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.tsx b/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.tsx new file mode 100644 index 00000000000..e2872ef76b0 --- /dev/null +++ b/docs/src/app/(private)/experiments/tabs/react-router-link-tabs.tsx @@ -0,0 +1,97 @@ +'use client'; +import * as React from 'react'; +import { HashRouter, Link, Navigate, useLocation } from 'react-router'; +import { Tabs } from '@base-ui/react/tabs'; +import '../../../../demo-data/theme/css-modules/theme.css'; +import classes from './react-router-link-tabs.module.css'; + +const routes = [ + { + path: '/overview', + label: 'Overview', + title: 'Overview route', + description: 'This tab is selected because the URL fragment resolves to /overview.', + }, + { + path: '/projects', + label: 'Projects', + title: 'Projects route', + description: + 'Clicking this link tab navigates with React Router and updates the selected value.', + }, + { + path: '/account', + label: 'Account', + title: 'Account route', + description: 'The tab renders as a React Router Link while keeping Base UI tab semantics.', + }, +] as const; + +export default function ReactRouterLinkTabsExperiment() { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( + + + + ); +} + +function RoutedTabs() { + const location = useLocation(); + const activeRoute = routes.find((route) => route.path === location.pathname); + + if (!activeRoute) { + return ; + } + + return ( +
+
+

React Router link tabs

+

+ Each tab is a Tabs.LinkTab rendered as a React Router Link. +

+
+ + +
+ Current fragment + #{location.pathname} +
+ + + {routes.map((route) => ( + } + value={route.path} + > + {route.label} + + ))} + + + + {routes.map((route) => ( + +
+

{route.path}

+

{route.title}

+

{route.description}

+
+
+ ))} +
+
+ ); +} diff --git a/docs/src/components/Demo/DemoFileSelector.tsx b/docs/src/components/Demo/DemoFileSelector.tsx index 13b589370ba..eafd12d2daf 100644 --- a/docs/src/components/Demo/DemoFileSelector.tsx +++ b/docs/src/components/Demo/DemoFileSelector.tsx @@ -12,6 +12,10 @@ interface DemoFileSelectorProps { type Tab = { id: string; name: string; slug?: string }; +function shouldPreventNavigation(event: React.MouseEvent) { + return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey; +} + export function DemoFileSelector({ files, selectedFileName, @@ -23,37 +27,17 @@ export function DemoFileSelector({ [files], ); - const modifierKeysRef = React.useRef(false); - const onValueChange = React.useCallback( (value: string) => { - // Don't change tab if modifier keys were pressed (user is opening in new tab) - if (modifierKeysRef.current) { - return; - } - selectFileName(value); onTabChange?.(); }, [selectFileName, onTabChange], ); - const onTabPointerDown = React.useCallback((event: React.PointerEvent) => { - // Track modifier keys before focus event fires - modifierKeysRef.current = event.ctrlKey || event.metaKey; - }, []); - const onTabClick = React.useCallback((event: React.MouseEvent) => { - // Allow Ctrl+Click (or Cmd+Click on Mac) to open in new tab - if (event.ctrlKey || event.metaKey) { - // Reset modifier keys flag after all event handlers complete - queueMicrotask(() => { - modifierKeysRef.current = false; - }); - } else { - // Prevent scroll jump to anchor + if (shouldPreventNavigation(event)) { event.preventDefault(); - modifierKeysRef.current = false; } }, []); @@ -69,17 +53,15 @@ export function DemoFileSelector({ {tabs.map((tab: Tab) => ( - } + {tab.name} - +
))} diff --git a/packages/react/src/tabs/index.parts.ts b/packages/react/src/tabs/index.parts.ts index b63d488656e..c9de585d6be 100644 --- a/packages/react/src/tabs/index.parts.ts +++ b/packages/react/src/tabs/index.parts.ts @@ -1,5 +1,6 @@ export { TabsRoot as Root } from './root/TabsRoot'; export { TabsTab as Tab } from './tab/TabsTab'; +export { TabsLinkTab as LinkTab } from './link-tab/TabsLinkTab'; export { TabsIndicator as Indicator } from './indicator/TabsIndicator'; export { TabsPanel as Panel } from './panel/TabsPanel'; export { TabsList as List } from './list/TabsList'; diff --git a/packages/react/src/tabs/index.ts b/packages/react/src/tabs/index.ts index 7e23fd5a807..82d771de376 100644 --- a/packages/react/src/tabs/index.ts +++ b/packages/react/src/tabs/index.ts @@ -3,5 +3,6 @@ export * as Tabs from './index.parts'; export type * from './root/TabsRoot'; export type * from './indicator/TabsIndicator'; export type * from './tab/TabsTab'; +export type * from './link-tab/TabsLinkTab'; export type * from './panel/TabsPanel'; export type * from './list/TabsList'; diff --git a/packages/react/src/tabs/link-tab/TabsLinkTab.test.tsx b/packages/react/src/tabs/link-tab/TabsLinkTab.test.tsx new file mode 100644 index 00000000000..39604dad150 --- /dev/null +++ b/packages/react/src/tabs/link-tab/TabsLinkTab.test.tsx @@ -0,0 +1,637 @@ +import * as React from 'react'; +import { expect, vi } from 'vitest'; +import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; +import { HashRouter, Route, Routes, Link, useLocation } from 'react-router'; +import { Tabs } from '@base-ui/react/tabs'; +import { createRenderer, describeConformance, isJSDOM } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLAnchorElement, + render: (node) => + render( + + {node} + , + ), + })); + + function LocationDisplay() { + const location = useLocation(); + return
{location.pathname}
; + } + + async function renderRouterLinkTabs(options: { disabledLink2?: boolean } = {}) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); + + return render( + + + page one} /> + page two} /> + + + + + + + } value="/"> + link 1 + + } value="/two"> + link 2 + + + + , + ); + } + + it('renders an anchor tab with state attributes', async () => { + await render( + + + + Overview + + + Settings + + + , + ); + + const tab = screen.getByRole('tab', { name: 'Overview' }); + const disabledTab = screen.getByRole('tab', { name: 'Settings' }); + + expect(tab).toBeInstanceOf(HTMLAnchorElement); + expect(tab).toHaveAttribute('href', '/overview'); + expect(tab).toHaveAttribute('aria-selected', 'true'); + expect(tab).toHaveAttribute('data-active'); + expect(tab).toHaveAttribute('data-orientation', 'vertical'); + expect(tab).toHaveAttribute('data-activation-direction', 'none'); + + expect(disabledTab).toHaveAttribute('aria-disabled', 'true'); + expect(disabledTab).toHaveAttribute('data-disabled'); + }); + + it('selects the corresponding panel when clicked', async () => { + const { user } = await render( + + + + Overview + + + Settings + + + + Overview panel + + + Settings panel + + , + ); + + await user.click(screen.getByRole('tab', { name: 'Settings' })); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + }); + + it('selects the corresponding panel when the click event prevents default', async () => { + const { user } = await render( + + + + Overview + + event.preventDefault()} + > + Settings + + + + Overview panel + + + Settings panel + + , + ); + + await user.click(screen.getByRole('tab', { name: 'Settings' })); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + }); + + it('does not select the corresponding panel when the click event prevents the Base UI handler', async () => { + const { user } = await render( + + + + Overview + + { + event.preventBaseUIHandler(); + }} + > + Settings + + + + Overview panel + + + Settings panel + + , + ); + + await user.click(screen.getByRole('tab', { name: 'Settings' })); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).not.toHaveAttribute('hidden'); + expect(panels[1]).toHaveAttribute('hidden'); + }); + + it('does not select or navigate when disabled', async () => { + await renderRouterLinkTabs({ disabledLink2: true }); + + const disabledTab = screen.getByRole('tab', { name: 'link 2' }); + + expect(disabledTab).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.click(disabledTab); + + expect(screen.getByTestId('location')).toHaveTextContent('/'); + expect(screen.getByRole('tab', { name: 'link 1' })).toHaveAttribute('aria-selected', 'true'); + expect(disabledTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('supports controlling router link selection from the current route', async () => { + function RoutedTabs() { + const location = useLocation(); + + return ( + + + page one} /> + page two} /> + + + + + + + } value="/"> + link 1 + + } value="/two"> + link 2 + + + + + ); + } + + const { user } = await render( + + + , + ); + + const link1 = screen.getByRole('tab', { name: 'link 1' }); + const link2 = screen.getByRole('tab', { name: 'link 2' }); + + expect(link1).toHaveAttribute('aria-selected', 'true'); + expect(link2).toHaveAttribute('aria-selected', 'false'); + + await user.click(link2); + + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/two'); + }); + + expect(link1).toHaveAttribute('aria-selected', 'false'); + expect(link2).toHaveAttribute('aria-selected', 'true'); + }); + + it('does not select the corresponding panel when disabled', async () => { + const { user } = await render( + + + + Overview + + + Settings + + + + Overview panel + + + Settings panel + + , + ); + + const disabledTab = screen.getByRole('tab', { name: 'Settings' }); + + expect(disabledTab).toHaveAttribute('aria-disabled', 'true'); + + await user.click(disabledTab); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).not.toHaveAttribute('hidden'); + expect(panels[1]).toHaveAttribute('hidden'); + }); + + it.skipIf(isJSDOM)('does not activate or navigate on modified link clicks', async () => { + await renderRouterLinkTabs(); + + const link1 = screen.getByRole('tab', { name: 'link 1' }); + const link2 = screen.getByRole('tab', { name: 'link 2' }); + const locationDisplay = screen.getByTestId('location'); + + for (const eventInit of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { altKey: true }, + { button: 1 }, + ]) { + fireEvent.click(link2, eventInit); + + expect(locationDisplay).toHaveTextContent('/'); + expect(link1).toHaveAttribute('aria-selected', 'true'); + expect(link2).toHaveAttribute('aria-selected', 'false'); + } + }); + + it('does not activate links that open outside the current page', async () => { + const handleChange = vi.fn(); + + await render( + + + + Overview + + } value="target"> + Target + + } value="download"> + Download + + + , + ); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + const targetTab = screen.getByRole('tab', { name: 'Target' }); + const downloadTab = screen.getByRole('tab', { name: 'Download' }); + + fireEvent.click(targetTab); + fireEvent.click(downloadTab); + + expect(handleChange).not.toHaveBeenCalled(); + expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + expect(targetTab).toHaveAttribute('aria-selected', 'false'); + expect(downloadTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('does not activate links that open outside the current page on focus', async () => { + await render( + + + + Overview + + } value="target"> + Target + + } value="download"> + Download + + + Modified + + + , + ); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + + for (const [tab, eventInit] of [ + [screen.getByRole('tab', { name: 'Target' }), {}], + [screen.getByRole('tab', { name: 'Download' }), {}], + [screen.getByRole('tab', { name: 'Modified' }), { ctrlKey: true }], + ] as const) { + fireEvent.pointerDown(tab, eventInit); + fireEvent.focus(tab); + fireEvent.pointerUp(document.body, eventInit); + fireEvent.click(tab, eventInit); + + expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + expect(tab).toHaveAttribute('aria-selected', 'false'); + } + }); + + it('supports keyboard navigation across link tabs', async () => { + await render( + + + + Overview + + + Projects + + + Account + + + , + ); + + const [overviewTab, projectsTab, accountTab] = screen.getAllByRole('tab'); + + await act(async () => { + overviewTab.focus(); + }); + + fireEvent.keyDown(overviewTab, { key: 'ArrowRight' }); + await flushMicrotasks(); + + expect(projectsTab).toHaveFocus(); + expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + expect(projectsTab).toHaveAttribute('aria-selected', 'false'); + + fireEvent.keyDown(projectsTab, { key: 'End' }); + await flushMicrotasks(); + + expect(accountTab).toHaveFocus(); + + fireEvent.keyDown(accountTab, { key: 'Home' }); + await flushMicrotasks(); + + expect(overviewTab).toHaveFocus(); + }); + + it('calls onValueChange in controlled mode without changing the selected link tab', async () => { + const handleChange = vi.fn(); + + await render( + + + + Overview + + + Projects + + + , + ); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + const projectsTab = screen.getByRole('tab', { name: 'Projects' }); + + fireEvent.click(projectsTab); + + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange.mock.calls[0][0]).toBe('projects'); + expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + expect(projectsTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('sets aria-controls and aria-labelledby between link tabs and panels', async () => { + await render( + + + + Overview + + + Projects + + + + Overview panel + + + Projects panel + + , + ); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + const projectsTab = screen.getByRole('tab', { name: 'Projects' }); + const [overviewPanel, projectsPanel] = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(overviewTab).toHaveAttribute('aria-controls', overviewPanel.id); + expect(projectsTab).toHaveAttribute('aria-controls', projectsPanel.id); + expect(overviewPanel).toHaveAttribute('aria-labelledby', overviewTab.id); + expect(projectsPanel).toHaveAttribute('aria-labelledby', projectsTab.id); + }); + + it('works when mixed with regular tabs', async () => { + const { user } = await render( + + + Overview + + Projects + + Account + + + Overview panel + + + Projects panel + + + Account panel + + , + ); + + await user.click(screen.getByRole('tab', { name: 'Projects' })); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + expect(panels[2]).toHaveAttribute('hidden'); + }); + + it('resets pointer press state after non-primary pointer interactions', async () => { + await render( + + + + Overview + + + Projects + + + Account + + + , + ); + + const projectsTab = screen.getByRole('tab', { name: 'Projects' }); + const accountTab = screen.getByRole('tab', { name: 'Account' }); + + fireEvent.pointerDown(accountTab, { button: 1 }); + fireEvent.pointerUp(document.body, { button: 1 }); + + act(() => { + projectsTab.focus(); + }); + + await waitFor(() => { + expect(projectsTab).toHaveAttribute('aria-selected', 'true'); + }); + }); + + it('selects a link tab with Space', async () => { + const { user } = await render( + + + + Overview + + + Projects + + + + Overview panel + + + Projects panel + + , + ); + + const projectsTab = screen.getByRole('tab', { name: 'Projects' }); + + act(() => { + projectsTab.focus(); + }); + + await waitFor(() => { + expect(projectsTab).toHaveFocus(); + }); + + await user.keyboard('[Space]'); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + }); + + it.skipIf(isJSDOM)('react-router activates with Enter', async () => { + const { user } = await renderRouterLinkTabs(); + + const link2 = screen.getByRole('tab', { name: 'link 2' }); + const locationDisplay = screen.getByTestId('location'); + + expect(screen.getByText(/page one/i)).not.toBe(null); + expect(locationDisplay).toHaveTextContent('/'); + + act(() => { + link2.focus(); + }); + + await waitFor(() => { + expect(link2).toHaveFocus(); + }); + + await user.keyboard('[Enter]'); + + expect(locationDisplay).toHaveTextContent('/two'); + expect(screen.getByText(/page two/i)).not.toBe(null); + }); + + it.skipIf(isJSDOM)('react-router activates with Space', async () => { + const { user } = await renderRouterLinkTabs(); + + const link2 = screen.getByRole('tab', { name: 'link 2' }); + const locationDisplay = screen.getByTestId('location'); + + expect(screen.getByText(/page one/i)).not.toBe(null); + expect(locationDisplay).toHaveTextContent('/'); + + act(() => { + link2.focus(); + }); + + await waitFor(() => { + expect(link2).toHaveFocus(); + }); + + await user.keyboard('[Space]'); + + expect(locationDisplay).toHaveTextContent('/two'); + expect(screen.getByText(/page two/i)).not.toBe(null); + }); + + it.skipIf(isJSDOM)('react-router can return with Enter', async () => { + const { user } = await renderRouterLinkTabs(); + + const link1 = screen.getByRole('tab', { name: 'link 1' }); + const link2 = screen.getByRole('tab', { name: 'link 2' }); + const locationDisplay = screen.getByTestId('location'); + + act(() => { + link2.focus(); + }); + + await waitFor(() => { + expect(link2).toHaveFocus(); + }); + + await user.keyboard('[Enter]'); + + expect(locationDisplay).toHaveTextContent('/two'); + + act(() => { + link1.focus(); + }); + + await waitFor(() => { + expect(link1).toHaveFocus(); + }); + + await user.keyboard('[Enter]'); + + expect(locationDisplay).toHaveTextContent('/'); + expect(screen.getByText(/page one/i)).not.toBe(null); + }); +}); diff --git a/packages/react/src/tabs/link-tab/TabsLinkTab.tsx b/packages/react/src/tabs/link-tab/TabsLinkTab.tsx new file mode 100644 index 00000000000..dbef2ca570e --- /dev/null +++ b/packages/react/src/tabs/link-tab/TabsLinkTab.tsx @@ -0,0 +1,116 @@ +'use client'; +import * as React from 'react'; +import { ownerWindow } from '@base-ui/utils/owner'; +import { useRenderElement } from '../../internals/useRenderElement'; +import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types'; +import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; +import type { TabsTab } from '../tab/TabsTab'; +import { useTabsTab } from '../tab/useTabsTab'; + +/** + * An individual interactive tab that navigates to a different page or section. + * Renders an `
` element. + * + * Documentation: [Base UI Tabs](https://base-ui.com/react/components/tabs) + */ +export const TabsLinkTab = React.forwardRef(function TabsLinkTab( + componentProps: TabsLinkTab.Props, + forwardedRef: React.ForwardedRef, +) { + const { + className, + disabled = false, + render, + value, + id: idProp, + style, + ...elementProps + } = componentProps; + + const { getTabProps, refs, state } = useTabsTab({ + disabled, + id: idProp, + nativeButton: false, + shouldSkipActivationOnPointerDown: shouldSkipTabActivationForPointerDown, + value, + }); + + const linkTabState: TabsLinkTabState = state; + const linkTabProps = { + onClick(event: BaseUIEvent>) { + if (shouldSkipTabActivationForClick(event)) { + event.preventBaseUIHandler(); + } + }, + }; + + return useRenderElement('a', componentProps, { + state: linkTabState, + ref: [forwardedRef, ...refs], + props: [linkTabProps, elementProps, getTabProps], + stateAttributesMapping: tabsStateAttributesMapping, + }); +}); + +function shouldSkipTabActivationForClick(event: React.MouseEvent) { + if (!isMouseEvent(event)) { + return false; + } + + return shouldSkipTabActivationForMouseEvent(event); +} + +function shouldSkipTabActivationForPointerDown(event: React.PointerEvent) { + return shouldSkipTabActivationForMouseEvent(event); +} + +function shouldSkipTabActivationForMouseEvent( + event: React.MouseEvent | React.PointerEvent, +) { + const element = event.currentTarget as HTMLAnchorElement; + + // Let normal link behavior win when the click does not navigate in the current page: + // new-window/download links and modified or non-primary clicks should not update + // the selected tab in this browsing context. + return ( + (element.target !== '' && element.target !== '_self') || + element.hasAttribute('download') || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ); +} + +function isMouseEvent(event: React.MouseEvent) { + const win = ownerWindow(event.currentTarget); + return event.nativeEvent instanceof win.MouseEvent; +} + +export interface TabsLinkTabState extends TabsTab.State {} + +export interface TabsLinkTabProps extends Omit< + BaseUIComponentProps<'a', TabsLinkTabState>, + 'download' | 'target' +> { + /** + * The value of the LinkTab. + */ + value: TabsTab.Value; + /** + * Whether the LinkTab is disabled. + * + * If the first LinkTab in a `` is disabled, it won't initially be selected. + * Instead, the next enabled LinkTab will be selected. + * However, it does not work like this during server-side rendering, as it is not known + * during pre-rendering which LinkTabs are disabled. + * To work around it, ensure that `defaultValue` or `value` on `` is set to an enabled LinkTab's value. + */ + disabled?: boolean | undefined; +} + +export namespace TabsLinkTab { + export type State = TabsLinkTabState; + export type Props = TabsLinkTabProps; +} diff --git a/packages/react/src/tabs/link-tab/TabsLinkTabDataAttributes.ts b/packages/react/src/tabs/link-tab/TabsLinkTabDataAttributes.ts new file mode 100644 index 00000000000..c1249b31323 --- /dev/null +++ b/packages/react/src/tabs/link-tab/TabsLinkTabDataAttributes.ts @@ -0,0 +1,20 @@ +export enum TabsLinkTabDataAttributes { + /** + * Indicates the direction of the activation (based on the previous active tab). + * @type {'left' | 'right' | 'up' | 'down' | 'none'} + */ + activationDirection = 'data-activation-direction', + /** + * Indicates the orientation of the tabs. + * @type {'horizontal' | 'vertical'} + */ + orientation = 'data-orientation', + /** + * Present when the tab is disabled. + */ + disabled = 'data-disabled', + /** + * Present when the tab is active. + */ + active = 'data-active', +} diff --git a/packages/react/src/tabs/tab/TabsTab.test.tsx b/packages/react/src/tabs/tab/TabsTab.test.tsx index fd6e585e77a..2776342d2f1 100644 --- a/packages/react/src/tabs/tab/TabsTab.test.tsx +++ b/packages/react/src/tabs/tab/TabsTab.test.tsx @@ -1,4 +1,6 @@ +import { expect } from 'vitest'; import { Tabs } from '@base-ui/react/tabs'; +import { act, screen, waitFor } from '@mui/internal-test-utils'; import { createRenderer, describeConformance } from '#test-utils'; describe('', () => { @@ -15,4 +17,91 @@ describe('', () => { , ), })); + + it('continues to support anchors through render and nativeButton=false', async () => { + const { user } = await render( + + + } value="overview"> + Overview + + } value="settings"> + Settings + + + + Overview panel + + + Settings panel + + , + ); + + const settingsTab = screen.getByRole('tab', { name: 'Settings' }); + + expect(settingsTab).toBeInstanceOf(HTMLAnchorElement); + expect(settingsTab).toHaveAttribute('href', '#settings'); + + await user.click(settingsTab); + + const panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + }); + + it('activates custom non-native tabs with the keyboard', async () => { + const { user } = await render( + + + } value="overview"> + Overview + + } value="settings"> + Settings + + + + Overview panel + + + Settings panel + + , + ); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + const settingsTab = screen.getByRole('tab', { name: 'Settings' }); + + act(() => { + settingsTab.focus(); + }); + + await waitFor(() => { + expect(settingsTab).toHaveFocus(); + }); + + await user.keyboard('[Enter]'); + + let panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).toHaveAttribute('hidden'); + expect(panels[1]).not.toHaveAttribute('hidden'); + + act(() => { + overviewTab.focus(); + }); + + await waitFor(() => { + expect(overviewTab).toHaveFocus(); + }); + + await user.keyboard('[Space]'); + + panels = screen.getAllByRole('tabpanel', { hidden: true }); + + expect(panels[0]).not.toHaveAttribute('hidden'); + expect(panels[1]).toHaveAttribute('hidden'); + }); }); diff --git a/packages/react/src/tabs/tab/TabsTab.tsx b/packages/react/src/tabs/tab/TabsTab.tsx index 77d4b867ec2..f90b494f2fb 100644 --- a/packages/react/src/tabs/tab/TabsTab.tsx +++ b/packages/react/src/tabs/tab/TabsTab.tsx @@ -1,19 +1,10 @@ 'use client'; import * as React from 'react'; -import { ownerDocument } from '@base-ui/utils/owner'; -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { useBaseUiId } from '../../internals/useBaseUiId'; import { useRenderElement } from '../../internals/useRenderElement'; import type { BaseUIComponentProps, NativeButtonProps } from '../../internals/types'; -import { useButton } from '../../internals/use-button'; -import { ACTIVE_COMPOSITE_ITEM } from '../../internals/composite/constants'; -import { useCompositeItem } from '../../internals/composite/item/useCompositeItem'; -import type { TabsRoot } from '../root/TabsRoot'; -import { useTabsRootContext } from '../root/TabsRootContext'; -import { useTabsListContext } from '../list/TabsListContext'; -import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; -import { REASONS } from '../../internals/reasons'; -import { activeElement, contains } from '../../floating-ui-react/utils'; +import type { TabsRoot, TabsRootState } from '../root/TabsRoot'; +import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; +import { useTabsTab } from './useTabsTab'; /** * An individual interactive tab button that toggles the corresponding panel. @@ -36,177 +27,19 @@ export const TabsTab = React.forwardRef(function TabsTab( ...elementProps } = componentProps; - const { value: activeTabValue, getTabPanelIdByValue, orientation } = useTabsRootContext(); - - const { - activateOnFocus, - highlightedTabIndex, - onTabActivation, - registerTabResizeObserverElement, - setHighlightedTabIndex, - tabsListElement, - } = useTabsListContext(); - - const id = useBaseUiId(idProp); - - const tabMetadata = React.useMemo(() => ({ disabled, id, value }), [disabled, id, value]); - - const { - compositeProps, - compositeRef, - index, - // hook is used instead of the CompositeItem component - // because the index is needed for Tab internals - } = useCompositeItem({ - metadata: tabMetadata, - }); - - const active = value === activeTabValue; - - const isNavigatingRef = React.useRef(false); - const tabElementRef = React.useRef(null); - - React.useEffect(() => { - const tabElement = tabElementRef.current; - if (!tabElement) { - return undefined; - } - - return registerTabResizeObserverElement(tabElement); - }, [registerTabResizeObserverElement]); - - // Keep the highlighted item in sync with the currently active tab - // when the value prop changes externally (controlled mode) - useIsoLayoutEffect(() => { - if (isNavigatingRef.current) { - isNavigatingRef.current = false; - return; - } - - if (!(active && index > -1 && highlightedTabIndex !== index)) { - return; - } - - // If focus is currently within the tabs list, don't override the roving - // focus highlight. This keeps keyboard navigation relative to the focused - // item after an external/asynchronous selection change. - const listElement = tabsListElement; - if (listElement != null) { - const activeEl = activeElement(ownerDocument(listElement)); - if (activeEl && contains(listElement, activeEl)) { - return; - } - } - - // Don't highlight disabled tabs to prevent them from interfering with keyboard navigation. - // Keyboard focus (tabIndex) should remain on an enabled tab even when a disabled tab is selected. - if (!disabled) { - setHighlightedTabIndex(index); - } - }, [active, index, highlightedTabIndex, setHighlightedTabIndex, disabled, tabsListElement]); - - const { getButtonProps, buttonRef } = useButton({ + const { getTabProps, refs, state } = useTabsTab({ disabled, - native: nativeButton, - focusableWhenDisabled: true, + id: idProp, + nativeButton, + value, }); - const tabPanelId = getTabPanelIdByValue(value); - - const isPressingRef = React.useRef(false); - const isMainButtonRef = React.useRef(false); - - function onClick(event: React.MouseEvent) { - if (active || disabled) { - return; - } - - onTabActivation( - value, - createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { - activationDirection: 'none', - }), - ); - } - - function onFocus(event: React.FocusEvent) { - if (active) { - return; - } - - // Only highlight enabled tabs when focused (disabled tabs remain focusable via focusableWhenDisabled). - if (index > -1 && !disabled) { - setHighlightedTabIndex(index); - } - - if (disabled) { - return; - } - - if ( - activateOnFocus && - (!isPressingRef.current || // keyboard or touch focus - (isPressingRef.current && isMainButtonRef.current)) // mouse focus - ) { - onTabActivation( - value, - createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { - activationDirection: 'none', - }), - ); - } - } - - function onPointerDown(event: React.PointerEvent) { - if (active || disabled) { - return; - } - - isPressingRef.current = true; - - function handlePointerUp() { - isPressingRef.current = false; - isMainButtonRef.current = false; - } - - if (!event.button || event.button === 0) { - isMainButtonRef.current = true; - - const doc = ownerDocument(event.currentTarget); - doc.addEventListener('pointerup', handlePointerUp, { once: true }); - } - } - - const state: TabsTabState = { - disabled, - active, - orientation, - }; - - const element = useRenderElement('button', componentProps, { + return useRenderElement('button', componentProps, { state, - ref: [forwardedRef, buttonRef, compositeRef, tabElementRef], - props: [ - compositeProps, - { - role: 'tab', - 'aria-controls': tabPanelId, - 'aria-selected': active, - id, - onClick, - onFocus, - onPointerDown, - [ACTIVE_COMPOSITE_ITEM as string]: active ? '' : undefined, - onKeyDownCapture() { - isNavigatingRef.current = true; - }, - }, - elementProps, - getButtonProps, - ], + ref: [forwardedRef, ...refs], + props: [elementProps, getTabProps], + stateAttributesMapping: tabsStateAttributesMapping, }); - - return element; }); export type TabsTabValue = any | null; @@ -231,7 +64,7 @@ export interface TabsTabMetadata { value: TabsTab.Value | undefined; } -export interface TabsTabState { +export interface TabsTabState extends TabsRootState { /** * Whether the component should ignore user interaction. */ diff --git a/packages/react/src/tabs/tab/useTabsTab.ts b/packages/react/src/tabs/tab/useTabsTab.ts new file mode 100644 index 00000000000..a53857b312a --- /dev/null +++ b/packages/react/src/tabs/tab/useTabsTab.ts @@ -0,0 +1,222 @@ +'use client'; +import * as React from 'react'; +import { ownerDocument } from '@base-ui/utils/owner'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { useBaseUiId } from '../../internals/useBaseUiId'; +import { ACTIVE_COMPOSITE_ITEM } from '../../internals/composite/constants'; +import { useCompositeItem } from '../../internals/composite/item/useCompositeItem'; +import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; +import type { HTMLProps } from '../../internals/types'; +import { useButton } from '../../internals/use-button'; +import { mergeProps } from '../../merge-props'; +import { activeElement, contains } from '../../floating-ui-react/utils'; +import { REASONS } from '../../internals/reasons'; +import { useTabsRootContext } from '../root/TabsRootContext'; +import { useTabsListContext } from '../list/TabsListContext'; +import type { TabsTab, TabsTabState } from './TabsTab'; + +export function useTabsTab(params: UseTabsTabParameters): UseTabsTabReturnValue { + const { disabled, id: idProp, nativeButton, value } = params; + + const { + value: activeTabValue, + getTabPanelIdByValue, + orientation, + tabActivationDirection, + } = useTabsRootContext(); + + const { + activateOnFocus, + highlightedTabIndex, + onTabActivation, + registerTabResizeObserverElement, + setHighlightedTabIndex, + tabsListElement, + } = useTabsListContext(); + + const id = useBaseUiId(idProp); + + const tabMetadata = React.useMemo(() => ({ disabled, id, value }), [disabled, id, value]); + + const { + compositeProps, + compositeRef, + index, + // hook is used instead of the CompositeItem component + // because the index is needed for Tab internals + } = useCompositeItem({ + metadata: tabMetadata, + }); + + const active = value === activeTabValue; + + const isNavigatingRef = React.useRef(false); + const tabElementRef = React.useRef(null); + + React.useEffect(() => { + const tabElement = tabElementRef.current; + if (!tabElement) { + return undefined; + } + + return registerTabResizeObserverElement(tabElement); + }, [registerTabResizeObserverElement]); + + // Keep the highlighted item in sync with the currently active tab + // when the value prop changes externally (controlled mode) + useIsoLayoutEffect(() => { + if (isNavigatingRef.current) { + isNavigatingRef.current = false; + return; + } + + if (!(active && index > -1 && highlightedTabIndex !== index)) { + return; + } + + // If focus is currently within the tabs list, don't override the roving + // focus highlight. This keeps keyboard navigation relative to the focused + // item after an external/asynchronous selection change. + const listElement = tabsListElement; + if (listElement != null) { + const activeEl = activeElement(ownerDocument(listElement)); + if (activeEl && contains(listElement, activeEl)) { + return; + } + } + + // Don't highlight disabled tabs to prevent them from interfering with keyboard navigation. + // Keyboard focus (tabIndex) should remain on an enabled tab even when a disabled tab is selected. + if (!disabled) { + setHighlightedTabIndex(index); + } + }, [active, index, highlightedTabIndex, setHighlightedTabIndex, disabled, tabsListElement]); + + const { getButtonProps, buttonRef } = useButton({ + disabled, + native: nativeButton, + focusableWhenDisabled: true, + }); + + const tabPanelId = getTabPanelIdByValue(value); + + const isPressingRef = React.useRef(false); + const isMainButtonRef = React.useRef(false); + const skipActivationOnFocusRef = React.useRef(false); + + function onClick(event: React.SyntheticEvent) { + if (disabled) { + event.preventDefault(); + return; + } + + if (active) { + return; + } + + onTabActivation( + value, + createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { + activationDirection: 'none', + }), + ); + } + + function onFocus(event: React.FocusEvent) { + if (active) { + return; + } + + // Only highlight enabled tabs when focused (disabled tabs remain focusable via focusableWhenDisabled). + if (index > -1 && !disabled) { + setHighlightedTabIndex(index); + } + + if (disabled) { + return; + } + + if ( + activateOnFocus && + !skipActivationOnFocusRef.current && + (!isPressingRef.current || // keyboard or touch focus + (isPressingRef.current && isMainButtonRef.current)) // mouse focus + ) { + onTabActivation( + value, + createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { + activationDirection: 'none', + }), + ); + } + } + + function onPointerDown(event: React.PointerEvent) { + if (active || disabled) { + return; + } + + isPressingRef.current = true; + isMainButtonRef.current = event.button === 0; + skipActivationOnFocusRef.current = params.shouldSkipActivationOnPointerDown?.(event) ?? false; + + function handlePointerUp() { + isPressingRef.current = false; + isMainButtonRef.current = false; + skipActivationOnFocusRef.current = false; + } + + const doc = ownerDocument(event.currentTarget); + doc.addEventListener('pointerup', handlePointerUp, { once: true }); + } + + const state: TabsTabState = { + disabled, + active, + orientation, + tabActivationDirection, + }; + + function getTabProps(externalProps?: HTMLProps): HTMLProps { + return mergeProps<'div'>( + compositeProps, + { + role: 'tab', + 'aria-controls': tabPanelId, + 'aria-selected': active, + id, + onClick, + onFocus, + onPointerDown, + [ACTIVE_COMPOSITE_ITEM as string]: active ? '' : undefined, + onKeyDownCapture() { + isNavigatingRef.current = true; + }, + }, + externalProps, + getButtonProps, + ); + } + + return { + getTabProps, + refs: [buttonRef, compositeRef, tabElementRef], + state, + }; +} + +export interface UseTabsTabParameters { + disabled: boolean; + id: string | undefined; + nativeButton: boolean; + shouldSkipActivationOnPointerDown?: + | ((event: React.PointerEvent) => boolean) + | undefined; + value: TabsTab.Value; +} + +export interface UseTabsTabReturnValue { + getTabProps: (externalProps?: HTMLProps) => HTMLProps; + refs: [React.Ref, React.Ref, React.RefObject]; + state: TabsTabState; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dffc270fca..43114714d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: react-is: specifier: ^19.2.5 version: 19.2.5 + react-router: + specifier: 7.14.2 + version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) remark: specifier: ^15.0.1 version: 15.0.1