diff --git a/docs/data/api/tabs-root.json b/docs/data/api/tabs-root.json index 7e6b2588ee..3f5059c7f8 100644 --- a/docs/data/api/tabs-root.json +++ b/docs/data/api/tabs-root.json @@ -1,7 +1,7 @@ { "props": { "className": { "type": { "name": "union", "description": "func
| string" } }, - "defaultValue": { "type": { "name": "any" } }, + "defaultValue": { "type": { "name": "any" }, "default": "0" }, "direction": { "type": { "name": "enum", "description": "'ltr'
| 'rtl'" }, "default": "'ltr'" diff --git a/docs/data/translations/api-docs/tab/tab.json b/docs/data/translations/api-docs/tab/tab.json index d486100235..5eff531661 100644 --- a/docs/data/translations/api-docs/tab/tab.json +++ b/docs/data/translations/api-docs/tab/tab.json @@ -6,7 +6,7 @@ }, "render": { "description": "A function to customize rendering of the component." }, "value": { - "description": "You can provide your own value. Otherwise, it falls back to the child position index." + "description": "The value of the Tab. When not specified, the value is the child position index." } }, "classDescriptions": {} diff --git a/docs/data/translations/api-docs/tabs-root/tabs-root.json b/docs/data/translations/api-docs/tabs-root/tabs-root.json index 5800f4b6b4..27020d8d4f 100644 --- a/docs/data/translations/api-docs/tabs-root/tabs-root.json +++ b/docs/data/translations/api-docs/tabs-root/tabs-root.json @@ -5,14 +5,14 @@ "description": "Class names applied to the element or a function that returns them based on the component's state." }, "defaultValue": { - "description": "The default value. Use when the component is not controlled." + "description": "The default value. Use when the component is not controlled. When the value is null, no Tab will be selected." }, "direction": { "description": "The direction of the text." }, "onValueChange": { "description": "Callback invoked when new value is being set." }, "orientation": { "description": "The component orientation (layout flow direction)." }, "render": { "description": "A function to customize rendering of the component." }, "value": { - "description": "The value of the currently selected Tab. If you don't want any selected Tab, you can set this prop to null." + "description": "The value of the currently selected Tab. Use when the component is controlled. When the value is null, no Tab will be selected." } }, "classDescriptions": {} diff --git a/docs/reference/generated/tab.json b/docs/reference/generated/tab.json index 733e416638..bf941b933d 100644 --- a/docs/reference/generated/tab.json +++ b/docs/reference/generated/tab.json @@ -12,7 +12,7 @@ }, "value": { "type": "any", - "description": "You can provide your own value. Otherwise, it falls back to the child position index." + "description": "The value of the Tab.\nWhen not specified, the value is the child position index." } } } diff --git a/docs/reference/generated/tabs-root.json b/docs/reference/generated/tabs-root.json index fbef93c3f9..33998d6c74 100644 --- a/docs/reference/generated/tabs-root.json +++ b/docs/reference/generated/tabs-root.json @@ -8,7 +8,8 @@ }, "defaultValue": { "type": "any", - "description": "The default value. Use when the component is not controlled." + "default": "0", + "description": "The default value. Use when the component is not controlled.\nWhen the value is `null`, no Tab will be selected." }, "direction": { "type": "'ltr' | 'rtl'", @@ -30,7 +31,7 @@ }, "value": { "type": "any", - "description": "The value of the currently selected `Tab`.\nIf you don't want any selected `Tab`, you can set this prop to `null`." + "description": "The value of the currently selected `Tab`. Use when the component is controlled.\nWhen the value is `null`, no Tab will be selected." } } } diff --git a/packages/react/src/Composite/Item/CompositeItem.tsx b/packages/react/src/Composite/Item/CompositeItem.tsx index 8b87646048..e26c24b27e 100644 --- a/packages/react/src/Composite/Item/CompositeItem.tsx +++ b/packages/react/src/Composite/Item/CompositeItem.tsx @@ -5,28 +5,26 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useForkRef } from '../../utils/useForkRef'; import { useCompositeRootContext } from '../Root/CompositeRootContext'; import { useCompositeItem } from './useCompositeItem'; +import { refType } from '../../utils/proptypes'; import type { BaseUIComponentProps } from '../../utils/types'; /** * @ignore - internal component. */ -const CompositeItem = React.forwardRef(function CompositeItem( - props: CompositeItem.Props, - forwardedRef: React.ForwardedRef, -) { - const { render, className, ...otherProps } = props; +function CompositeItem(props: CompositeItem.Props) { + const { render, className, itemRef, metadata, ...otherProps } = props; - const { activeIndex } = useCompositeRootContext(); - const { getItemProps, ref, index } = useCompositeItem(); + const { highlightedIndex } = useCompositeRootContext(); + const { getItemProps, ref, index } = useCompositeItem({ metadata }); const ownerState: CompositeItem.OwnerState = React.useMemo( () => ({ - active: index === activeIndex, + highlighted: index === highlightedIndex, }), - [index, activeIndex], + [index, highlightedIndex], ); - const mergedRef = useForkRef(forwardedRef, ref); + const mergedRef = useForkRef(itemRef, ref); const { renderElement } = useComponentRenderer({ propGetter: getItemProps, @@ -38,16 +36,23 @@ const CompositeItem = React.forwardRef(function CompositeItem( }); return renderElement(); -}); +} namespace CompositeItem { export interface OwnerState { - active: boolean; + highlighted: boolean; } - export interface Props extends BaseUIComponentProps<'div', OwnerState> {} + export interface Props + extends Omit, 'itemRef'> { + // the itemRef name collides with https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/itemref + itemRef?: React.RefObject; + metadata?: Metadata; + } } +export { CompositeItem }; + CompositeItem.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -61,10 +66,16 @@ CompositeItem.propTypes /* remove-proptypes */ = { * Class names applied to the element or a function that returns them based on the component's state. */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + itemRef: refType, + /** + * @ignore + */ + metadata: PropTypes.any, /** * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), } as any; - -export { CompositeItem }; diff --git a/packages/react/src/Composite/Item/useCompositeItem.ts b/packages/react/src/Composite/Item/useCompositeItem.ts index 70ab405323..f182eb157b 100644 --- a/packages/react/src/Composite/Item/useCompositeItem.ts +++ b/packages/react/src/Composite/Item/useCompositeItem.ts @@ -4,26 +4,30 @@ import { useCompositeRootContext } from '../Root/CompositeRootContext'; import { useCompositeListItem } from '../List/useCompositeListItem'; import { mergeReactProps } from '../../utils/mergeReactProps'; +export interface UseCompositeItemParameters { + metadata?: Metadata; +} + /** * * API: * * - [useCompositeItem API](https://mui.com/base-ui/api/use-composite-item/) */ -export function useCompositeItem() { - const { activeIndex, onActiveIndexChange } = useCompositeRootContext(); - const { ref, index } = useCompositeListItem(); - const isActive = activeIndex === index; +export function useCompositeItem(params: UseCompositeItemParameters = {}) { + const { highlightedIndex, onHighlightedIndexChange } = useCompositeRootContext(); + const { ref, index } = useCompositeListItem(params); + const isHighlighted = highlightedIndex === index; const getItemProps = React.useCallback( (externalProps = {}) => mergeReactProps<'div'>(externalProps, { - tabIndex: isActive ? 0 : -1, + tabIndex: isHighlighted ? 0 : -1, onFocus() { - onActiveIndexChange(index); + onHighlightedIndexChange(index); }, }), - [isActive, index, onActiveIndexChange], + [isHighlighted, index, onHighlightedIndexChange], ); return React.useMemo( diff --git a/packages/react/src/Composite/List/CompositeList.tsx b/packages/react/src/Composite/List/CompositeList.tsx index 09815eb7e6..be2ed3827c 100644 --- a/packages/react/src/Composite/List/CompositeList.tsx +++ b/packages/react/src/Composite/List/CompositeList.tsx @@ -2,6 +2,7 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import { fastObjectShallowCompare } from '../../utils/fastObjectShallowCompare'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { CompositeListContext } from './CompositeListContext'; @@ -22,12 +23,22 @@ function sortByDocumentPosition(a: Node, b: Node) { return 0; } -function areMapsEqual(map1: Map, map2: Map) { +export type CompositeMetadata = { index?: number | null } & CustomMetadata; + +function areMapsEqual( + map1: Map | null>, + map2: Map | null>, +) { if (map1.size !== map2.size) { return false; } for (const [key, value] of map1.entries()) { - if (value !== map2.get(key)) { + const value2 = map2.get(key); + // compare the index before comparing everything else + if (value?.index !== value2?.index) { + return false; + } + if (value2 !== undefined && !fastObjectShallowCompare(value, value2)) { return false; } } @@ -38,13 +49,13 @@ function areMapsEqual(map1: Map, map2: Map(props: CompositeList.Props) { + const { children, elementsRef, labelsRef, onMapChange } = props; - const [map, setMap] = React.useState(() => new Map()); + const [map, setMap] = React.useState(() => new Map | null>()); - const register = React.useCallback((node: Node) => { - setMap((prevMap) => new Map(prevMap).set(node, null)); + const register = React.useCallback((node: Node, metadata: Metadata) => { + setMap((prevMap) => new Map(prevMap).set(node, metadata ?? null)); }, []); const unregister = React.useCallback((node: Node) => { @@ -57,16 +68,20 @@ function CompositeList(props: CompositeList.Props) { useEnhancedEffect(() => { const newMap = new Map(map); + const nodes = Array.from(newMap.keys()).sort(sortByDocumentPosition); nodes.forEach((node, index) => { - newMap.set(node, index); + const metadata = map.get(node) ?? ({} as CompositeMetadata); + + newMap.set(node, { ...metadata, index }); }); if (!areMapsEqual(map, newMap)) { setMap(newMap); + onMapChange?.(newMap); } - }, [map]); + }, [map, onMapChange]); const contextValue = React.useMemo( () => ({ register, unregister, map, elementsRef, labelsRef }), @@ -79,21 +94,24 @@ function CompositeList(props: CompositeList.Props) { } namespace CompositeList { - export interface Props { + export interface Props { children: React.ReactNode; /** * A ref to the list of HTML elements, ordered by their index. * `useListNavigation`'s `listRef` prop. */ - elementsRef: React.MutableRefObject>; + elementsRef: React.RefObject>; /** * A ref to the list of element labels, ordered by their index. * `useTypeahead`'s `listRef` prop. */ - labelsRef?: React.MutableRefObject>; + labelsRef?: React.RefObject>; + onMapChange?: (newMap: Map | null>) => void; } } +export { CompositeList }; + CompositeList.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -115,6 +133,8 @@ CompositeList.propTypes /* remove-proptypes */ = { labelsRef: PropTypes.shape({ current: PropTypes.arrayOf(PropTypes.string).isRequired, }), + /** + * @ignore + */ + onMapChange: PropTypes.func, } as any; - -export { CompositeList }; diff --git a/packages/react/src/Composite/List/CompositeListContext.ts b/packages/react/src/Composite/List/CompositeListContext.ts index 4f1358525f..4bd95392c0 100644 --- a/packages/react/src/Composite/List/CompositeListContext.ts +++ b/packages/react/src/Composite/List/CompositeListContext.ts @@ -1,15 +1,15 @@ 'use client'; import * as React from 'react'; -export interface CompositeListContextValue { - register: (node: Node) => void; +export interface CompositeListContextValue { + register: (node: Node, metadata: Metadata) => void; unregister: (node: Node) => void; - map: Map; - elementsRef: React.MutableRefObject>; - labelsRef?: React.MutableRefObject>; + map: Map; + elementsRef: React.RefObject>; + labelsRef?: React.RefObject>; } -export const CompositeListContext = React.createContext({ +export const CompositeListContext = React.createContext>({ register: () => {}, unregister: () => {}, map: new Map(), diff --git a/packages/react/src/Composite/List/useCompositeListItem.ts b/packages/react/src/Composite/List/useCompositeListItem.ts index c4cd337da9..ef21fb47e2 100644 --- a/packages/react/src/Composite/List/useCompositeListItem.ts +++ b/packages/react/src/Composite/List/useCompositeListItem.ts @@ -3,8 +3,9 @@ import * as React from 'react'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useCompositeListContext } from './CompositeListContext'; -export interface UseCompositeListItemParameters { +export interface UseCompositeListItemParameters { label?: string | null; + metadata?: Metadata; } interface UseCompositeListItemReturnValue { @@ -20,10 +21,10 @@ interface UseCompositeListItemReturnValue { * * - [useCompositeListItem API](https://mui.com/base-ui/api/use-composite-list-item/) */ -export function useCompositeListItem( - params: UseCompositeListItemParameters = {}, +export function useCompositeListItem( + params: UseCompositeListItemParameters = {}, ): UseCompositeListItemReturnValue { - const { label } = params; + const { label, metadata } = params; const { register, unregister, map, elementsRef, labelsRef } = useCompositeListContext(); @@ -49,16 +50,17 @@ export function useCompositeListItem( useEnhancedEffect(() => { const node = componentRef.current; if (node) { - register(node); + register(node, metadata); return () => { unregister(node); }; } return undefined; - }, [register, unregister]); + }, [register, unregister, metadata]); useEnhancedEffect(() => { - const i = componentRef.current ? map.get(componentRef.current) : null; + const i = componentRef.current ? map.get(componentRef.current)?.index : null; + if (i != null) { setIndex(i); } diff --git a/packages/react/src/Composite/Root/CompositeRoot.test.tsx b/packages/react/src/Composite/Root/CompositeRoot.test.tsx index 401729c9ca..98b7d4523e 100644 --- a/packages/react/src/Composite/Root/CompositeRoot.test.tsx +++ b/packages/react/src/Composite/Root/CompositeRoot.test.tsx @@ -18,9 +18,12 @@ describe('Composite', () => { describe('list', () => { it('controlled mode', async () => { function App() { - const [activeIndex, setActiveIndex] = React.useState(0); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); return ( - + 1 2 3 @@ -36,29 +39,29 @@ describe('Composite', () => { act(() => item1.focus()); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); fireEvent.keyDown(item3, { key: 'ArrowUp' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowUp' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); expect(item1).to.have.attribute('tabindex', '0'); expect(item1).toHaveFocus(); }); @@ -78,29 +81,29 @@ describe('Composite', () => { act(() => item1.focus()); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); fireEvent.keyDown(item3, { key: 'ArrowUp' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowUp' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); expect(item1).to.have.attribute('tabindex', '0'); expect(item1).toHaveFocus(); }); @@ -119,11 +122,11 @@ describe('Composite', () => { const item3 = getByTestId('3'); act(() => item3.focus()); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); fireEvent.keyDown(item3, { key: 'Home' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); expect(item1).to.have.attribute('tabindex', '0'); expect(item1).toHaveFocus(); }); @@ -141,11 +144,11 @@ describe('Composite', () => { const item3 = getByTestId('3'); act(() => item1.focus()); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'End' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); }); @@ -169,40 +172,40 @@ describe('Composite', () => { act(() => item1.focus()); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); fireEvent.keyDown(item3, { key: 'ArrowRight' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowRight' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); expect(item1).to.have.attribute('tabindex', '0'); expect(item1).toHaveFocus(); // loop backward fireEvent.keyDown(item1, { key: 'ArrowRight' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); }); @@ -224,41 +227,41 @@ describe('Composite', () => { act(() => item1.focus()); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); fireEvent.keyDown(item1, { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); fireEvent.keyDown(item3, { key: 'ArrowRight' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowRight' }); await flushMicrotasks(); - expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('data-highlighted'); expect(item1).to.have.attribute('tabindex', '0'); expect(item1).toHaveFocus(); fireEvent.keyDown(item1, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('data-highlighted'); expect(item2).to.have.attribute('tabindex', '0'); expect(item2).toHaveFocus(); fireEvent.keyDown(item2, { key: 'ArrowDown' }); await flushMicrotasks(); - expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('data-highlighted'); expect(item3).to.have.attribute('tabindex', '0'); expect(item3).toHaveFocus(); }); @@ -283,51 +286,51 @@ describe('Composite', () => { const { getByTestId } = await render(); act(() => getByTestId('1').focus()); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); fireEvent.keyDown(getByTestId('1'), { key: 'ArrowDown' }); await flushMicrotasks(); - expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('data-highlighted'); expect(getByTestId('4')).to.have.attribute('tabindex', '0'); expect(getByTestId('4')).toHaveFocus(); fireEvent.keyDown(getByTestId('4'), { key: 'ArrowRight' }); await flushMicrotasks(); - expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('data-highlighted'); expect(getByTestId('5')).to.have.attribute('tabindex', '0'); expect(getByTestId('5')).toHaveFocus(); fireEvent.keyDown(getByTestId('5'), { key: 'ArrowDown' }); await flushMicrotasks(); - expect(getByTestId('8')).to.have.attribute('data-active'); + expect(getByTestId('8')).to.have.attribute('data-highlighted'); expect(getByTestId('8')).to.have.attribute('tabindex', '0'); expect(getByTestId('8')).toHaveFocus(); fireEvent.keyDown(getByTestId('8'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('7')).to.have.attribute('data-active'); + expect(getByTestId('7')).to.have.attribute('data-highlighted'); expect(getByTestId('7')).to.have.attribute('tabindex', '0'); expect(getByTestId('7')).toHaveFocus(); fireEvent.keyDown(getByTestId('7'), { key: 'ArrowUp' }); await flushMicrotasks(); - expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('data-highlighted'); expect(getByTestId('4')).to.have.attribute('tabindex', '0'); expect(getByTestId('4')).toHaveFocus(); act(() => getByTestId('9').focus()); await flushMicrotasks(); - expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('data-highlighted'); expect(getByTestId('9')).to.have.attribute('tabindex', '0'); fireEvent.keyDown(getByTestId('9'), { key: 'Home' }); await flushMicrotasks(); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); expect(getByTestId('1')).to.have.attribute('tabindex', '0'); fireEvent.keyDown(getByTestId('1'), { key: 'End' }); await flushMicrotasks(); - expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('data-highlighted'); expect(getByTestId('9')).to.have.attribute('tabindex', '0'); }); @@ -346,40 +349,40 @@ describe('Composite', () => { ); act(() => getByTestId('1').focus()); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); fireEvent.keyDown(getByTestId('1'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('2')).to.have.attribute('data-active'); + expect(getByTestId('2')).to.have.attribute('data-highlighted'); expect(getByTestId('2')).to.have.attribute('tabindex', '0'); expect(getByTestId('2')).toHaveFocus(); fireEvent.keyDown(getByTestId('2'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('3')).to.have.attribute('data-active'); + expect(getByTestId('3')).to.have.attribute('data-highlighted'); expect(getByTestId('3')).to.have.attribute('tabindex', '0'); expect(getByTestId('3')).toHaveFocus(); fireEvent.keyDown(getByTestId('3'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('data-highlighted'); expect(getByTestId('4')).to.have.attribute('tabindex', '0'); expect(getByTestId('4')).toHaveFocus(); fireEvent.keyDown(getByTestId('4'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('data-highlighted'); expect(getByTestId('5')).to.have.attribute('tabindex', '0'); expect(getByTestId('5')).toHaveFocus(); fireEvent.keyDown(getByTestId('5'), { key: 'Home' }); await flushMicrotasks(); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); expect(getByTestId('1')).to.have.attribute('tabindex', '0'); fireEvent.keyDown(getByTestId('1'), { key: 'End' }); await flushMicrotasks(); - expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('data-highlighted'); expect(getByTestId('9')).to.have.attribute('tabindex', '0'); }); @@ -397,46 +400,46 @@ describe('Composite', () => { ); act(() => getByTestId('1').focus()); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); fireEvent.keyDown(getByTestId('1'), { key: 'ArrowDown' }); await flushMicrotasks(); - expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('data-highlighted'); expect(getByTestId('4')).to.have.attribute('tabindex', '0'); expect(getByTestId('4')).toHaveFocus(); fireEvent.keyDown(getByTestId('4'), { key: 'ArrowLeft' }); await flushMicrotasks(); - expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('data-highlighted'); expect(getByTestId('5')).to.have.attribute('tabindex', '0'); expect(getByTestId('5')).toHaveFocus(); fireEvent.keyDown(getByTestId('5'), { key: 'ArrowDown' }); await flushMicrotasks(); - expect(getByTestId('8')).to.have.attribute('data-active'); + expect(getByTestId('8')).to.have.attribute('data-highlighted'); expect(getByTestId('8')).to.have.attribute('tabindex', '0'); expect(getByTestId('8')).toHaveFocus(); fireEvent.keyDown(getByTestId('8'), { key: 'ArrowRight' }); await flushMicrotasks(); - expect(getByTestId('7')).to.have.attribute('data-active'); + expect(getByTestId('7')).to.have.attribute('data-highlighted'); expect(getByTestId('7')).to.have.attribute('tabindex', '0'); expect(getByTestId('7')).toHaveFocus(); fireEvent.keyDown(getByTestId('7'), { key: 'ArrowUp' }); await flushMicrotasks(); - expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('data-highlighted'); expect(getByTestId('4')).to.have.attribute('tabindex', '0'); expect(getByTestId('4')).toHaveFocus(); fireEvent.keyDown(getByTestId('4'), { key: 'End' }); await flushMicrotasks(); - expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('data-highlighted'); expect(getByTestId('9')).to.have.attribute('tabindex', '0'); fireEvent.keyDown(getByTestId('9'), { key: 'Home' }); await flushMicrotasks(); - expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('data-highlighted'); expect(getByTestId('1')).to.have.attribute('tabindex', '0'); }); }); diff --git a/packages/react/src/Composite/Root/CompositeRoot.tsx b/packages/react/src/Composite/Root/CompositeRoot.tsx index 5cdeff18e1..ba93408eff 100644 --- a/packages/react/src/Composite/Root/CompositeRoot.tsx +++ b/packages/react/src/Composite/Root/CompositeRoot.tsx @@ -2,48 +2,50 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import { CompositeList } from '../List/CompositeList'; +import { CompositeList, type CompositeMetadata } from '../List/CompositeList'; import { useCompositeRoot } from './useCompositeRoot'; import { CompositeRootContext } from './CompositeRootContext'; +import { refType } from '../../utils/proptypes'; import type { BaseUIComponentProps } from '../../utils/types'; import type { Dimensions } from '../composite'; /** * @ignore - internal component. */ -const CompositeRoot = React.forwardRef(function CompositeRoot( - props: CompositeRoot.Props, - forwardedRef: React.ForwardedRef, -) { +function CompositeRoot(props: CompositeRoot.Props) { const { render, className, - activeIndex: activeIndexProp, - onActiveIndexChange: onActiveIndexChangeProp, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, orientation, dense, itemSizes, loop, cols, enableHomeAndEndKeys, + onMapChange, + stopEventPropagation, + rootRef, ...otherProps } = props; - const { getRootProps, activeIndex, onActiveIndexChange, elementsRef } = useCompositeRoot({ - itemSizes, - cols, - loop, - dense, - orientation, - activeIndex: activeIndexProp, - onActiveIndexChange: onActiveIndexChangeProp, - rootRef: forwardedRef, - enableHomeAndEndKeys, - }); + const { getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef } = + useCompositeRoot({ + itemSizes, + cols, + loop, + dense, + orientation, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, + rootRef, + stopEventPropagation, + enableHomeAndEndKeys, + }); const { renderElement } = useComponentRenderer({ propGetter: getRootProps, - ref: forwardedRef, render: render ?? 'div', ownerState: {}, className, @@ -51,41 +53,44 @@ const CompositeRoot = React.forwardRef(function CompositeRoot( }); const contextValue: CompositeRootContext = React.useMemo( - () => ({ activeIndex, onActiveIndexChange }), - [activeIndex, onActiveIndexChange], + () => ({ highlightedIndex, onHighlightedIndexChange }), + [highlightedIndex, onHighlightedIndexChange], ); return ( - {renderElement()} + elementsRef={elementsRef} onMapChange={onMapChange}> + {renderElement()} + ); -}); +} namespace CompositeRoot { export interface OwnerState {} - export interface Props extends BaseUIComponentProps<'div', OwnerState> { + export interface Props extends BaseUIComponentProps<'div', OwnerState> { orientation?: 'horizontal' | 'vertical' | 'both'; cols?: number; loop?: boolean; - activeIndex?: number; - onActiveIndexChange?: (index: number) => void; + highlightedIndex?: number; + onHighlightedIndexChange?: (index: number) => void; itemSizes?: Dimensions[]; dense?: boolean; enableHomeAndEndKeys?: boolean; + onMapChange?: (newMap: Map | null>) => void; + stopEventPropagation?: boolean; + rootRef?: React.RefObject; } } +export { CompositeRoot }; + CompositeRoot.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ - /** - * @ignore - */ - activeIndex: PropTypes.number, /** * @ignore */ @@ -106,6 +111,10 @@ CompositeRoot.propTypes /* remove-proptypes */ = { * @ignore */ enableHomeAndEndKeys: PropTypes.bool, + /** + * @ignore + */ + highlightedIndex: PropTypes.number, /** * @ignore */ @@ -122,7 +131,11 @@ CompositeRoot.propTypes /* remove-proptypes */ = { /** * @ignore */ - onActiveIndexChange: PropTypes.func, + onHighlightedIndexChange: PropTypes.func, + /** + * @ignore + */ + onMapChange: PropTypes.func, /** * @ignore */ @@ -131,6 +144,12 @@ CompositeRoot.propTypes /* remove-proptypes */ = { * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + rootRef: refType, + /** + * @ignore + */ + stopEventPropagation: PropTypes.bool, } as any; - -export { CompositeRoot }; diff --git a/packages/react/src/Composite/Root/CompositeRootContext.ts b/packages/react/src/Composite/Root/CompositeRootContext.ts index f92f119c95..347ec9d1a7 100644 --- a/packages/react/src/Composite/Root/CompositeRootContext.ts +++ b/packages/react/src/Composite/Root/CompositeRootContext.ts @@ -2,8 +2,8 @@ import * as React from 'react'; export interface CompositeRootContext { - activeIndex: number; - onActiveIndexChange: (index: number) => void; + highlightedIndex: number; + onHighlightedIndexChange: (index: number) => void; } export const CompositeRootContext = React.createContext( diff --git a/packages/react/src/Composite/Root/useCompositeRoot.ts b/packages/react/src/Composite/Root/useCompositeRoot.ts index ca5a49748f..4311f78755 100644 --- a/packages/react/src/Composite/Root/useCompositeRoot.ts +++ b/packages/react/src/Composite/Root/useCompositeRoot.ts @@ -34,8 +34,8 @@ export interface UseCompositeRootParameters { orientation?: 'horizontal' | 'vertical' | 'both'; cols?: number; loop?: boolean; - activeIndex?: number; - onActiveIndexChange?: (index: number) => void; + highlightedIndex?: number; + onHighlightedIndexChange?: (index: number) => void; dense?: boolean; itemSizes?: Array; rootRef?: React.Ref; @@ -45,6 +45,12 @@ export interface UseCompositeRootParameters { * @default false */ enableHomeAndEndKeys?: boolean; + /** + * When `true`, keypress events on Composite's navigation keys + * be stopped with event.stopPropagation() + * @default false + */ + stopEventPropagation?: boolean; } // Advanced options of Composite, to be implemented later if needed. @@ -60,18 +66,21 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { loop = true, dense = false, orientation = 'both', - activeIndex: externalActiveIndex, - onActiveIndexChange: externalSetActiveIndex, + highlightedIndex: externalHighlightedIndex, + onHighlightedIndexChange: externalSetHighlightedIndex, rootRef: externalRef, enableHomeAndEndKeys = false, + stopEventPropagation = false, } = params; - const [internalActiveIndex, internalSetActiveIndex] = React.useState(0); + const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0); const isGrid = cols > 1; - const activeIndex = externalActiveIndex ?? internalActiveIndex; - const onActiveIndexChange = useEventCallback(externalSetActiveIndex ?? internalSetActiveIndex); + const highlightedIndex = externalHighlightedIndex ?? internalHighlightedIndex; + const onHighlightedIndexChange = useEventCallback( + externalSetHighlightedIndex ?? internalSetHighlightedIndex, + ); const textDirectionRef = React.useRef(null); @@ -103,7 +112,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { const isRtl = textDirectionRef?.current === 'rtl'; - let nextIndex = activeIndex; + let nextIndex = highlightedIndex; const minIndex = getMinIndex(elementsRef, disabledIndices); const maxIndex = getMaxIndex(elementsRef, disabledIndices); @@ -156,7 +165,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { minIndex: minGridIndex, maxIndex: maxGridIndex, prevIndex: getCellIndexOfCorner( - activeIndex > maxIndex ? minIndex : activeIndex, + highlightedIndex > maxIndex ? minIndex : highlightedIndex, sizes, cellMap, cols, @@ -204,7 +213,10 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { } } - if (nextIndex === activeIndex && [...toEndKeys, ...toStartKeys].includes(event.key)) { + if ( + nextIndex === highlightedIndex && + [...toEndKeys, ...toStartKeys].includes(event.key) + ) { if (loop && nextIndex === maxIndex && toEndKeys.includes(event.key)) { nextIndex = minIndex; } else if (loop && nextIndex === minIndex && toStartKeys.includes(event.key)) { @@ -218,14 +230,16 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { } } - if (nextIndex !== activeIndex && !isIndexOutOfBounds(elementsRef, nextIndex)) { - event.stopPropagation(); + if (nextIndex !== highlightedIndex && !isIndexOutOfBounds(elementsRef, nextIndex)) { + if (stopEventPropagation) { + event.stopPropagation(); + } if (preventedKeys.includes(event.key)) { event.preventDefault(); } - onActiveIndexChange(nextIndex); + onHighlightedIndexChange(nextIndex); // Wait for FocusManager `returnFocus` to execute. queueMicrotask(() => { @@ -235,7 +249,8 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { }, }), [ - activeIndex, + highlightedIndex, + stopEventPropagation, cols, dense, elementsRef, @@ -243,7 +258,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { itemSizes, loop, mergedRef, - onActiveIndexChange, + onHighlightedIndexChange, orientation, enableHomeAndEndKeys, ], @@ -252,10 +267,10 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { return React.useMemo( () => ({ getRootProps, - activeIndex, - onActiveIndexChange, + highlightedIndex, + onHighlightedIndexChange, elementsRef, }), - [getRootProps, activeIndex, onActiveIndexChange, elementsRef], + [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef], ); } diff --git a/packages/react/src/Tabs/Root/TabsProvider.tsx b/packages/react/src/Tabs/Root/TabsProvider.tsx deleted file mode 100644 index d8d2e55157..0000000000 --- a/packages/react/src/Tabs/Root/TabsProvider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import { TabsRootContext } from './TabsRootContext'; -import { CompoundComponentContext, CompoundComponentContextValue } from '../../useCompound'; - -export type TabPanelMetadata = { - id: string | undefined; - ref: React.RefObject; -}; - -export type TabsProviderValue = CompoundComponentContextValue & - TabsRootContext; - -export interface TabsProviderProps { - value: TabsProviderValue; - children: React.ReactNode; -} - -/** - * Sets up the contexts for the underlying Tab and TabPanel components. - * - * @ignore - do not document. - */ -const TabsProvider: React.FC = function TabsProvider(props) { - const { value: valueProp, children } = props; - const { - direction, - getItemIndex, - onSelected, - orientation, - registerItem, - registerTabIdLookup, - totalSubitemCount, - value, - getTabId, - getTabPanelId, - tabActivationDirection, - } = valueProp; - - const compoundComponentContextValue: CompoundComponentContextValue = - React.useMemo( - () => ({ - getItemIndex, - registerItem, - totalSubitemCount, - }), - [registerItem, getItemIndex, totalSubitemCount], - ); - - const tabsContextValue: TabsRootContext = React.useMemo( - () => ({ - direction, - getTabId, - getTabPanelId, - onSelected, - orientation, - registerTabIdLookup, - value, - tabActivationDirection, - }), - [ - direction, - getTabId, - getTabPanelId, - onSelected, - orientation, - registerTabIdLookup, - value, - tabActivationDirection, - ], - ); - - return ( - - {children} - - ); -}; - -TabsProvider.propTypes /* remove-proptypes */ = { - // ┌────────────────────────────── Warning ──────────────────────────────┐ - // │ These PropTypes are generated from the TypeScript type definitions. │ - // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ - // └─────────────────────────────────────────────────────────────────────┘ - /** - * @ignore - */ - children: PropTypes.node, - /** - * @ignore - */ - value: PropTypes.shape({ - direction: PropTypes.oneOf(['ltr', 'rtl']).isRequired, - getItemIndex: PropTypes.func.isRequired, - getTabId: PropTypes.func.isRequired, - getTabPanelId: PropTypes.func.isRequired, - onSelected: PropTypes.func.isRequired, - orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired, - registerItem: PropTypes.func.isRequired, - registerTabIdLookup: PropTypes.func.isRequired, - tabActivationDirection: PropTypes.oneOf(['down', 'left', 'none', 'right', 'up']).isRequired, - totalSubitemCount: PropTypes.number.isRequired, - value: PropTypes.any, - }).isRequired, -} as any; - -export { TabsProvider }; diff --git a/packages/react/src/Tabs/Root/TabsRoot.test.tsx b/packages/react/src/Tabs/Root/TabsRoot.test.tsx index 09fc8e808a..a686c32787 100644 --- a/packages/react/src/Tabs/Root/TabsRoot.test.tsx +++ b/packages/react/src/Tabs/Root/TabsRoot.test.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import { act, describeSkipIf, fireEvent, screen } from '@mui/internal-test-utils'; +import { act, describeSkipIf, flushMicrotasks, fireEvent, screen } from '@mui/internal-test-utils'; import { Tabs } from '@base-ui-components/react/Tabs'; import { createRenderer, describeConformance } from '#test-utils'; +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + describe('', () => { const { render } = createRenderer(); @@ -214,6 +216,7 @@ describe('', () => { }); fireEvent.keyDown(firstTab, { key: 'ArrowRight' }); + await flushMicrotasks(); expect(handleChange.callCount).to.equal(1); expect(handleChange.firstCall.args[0]).to.equal(1); @@ -274,183 +277,156 @@ describe('', () => { ].forEach((entry) => { const [orientation, direction, previousItemKey, nextItemKey] = entry; - describe(`when focus is on a tab element in a ${orientation} ${direction} tablist`, () => { - describe(previousItemKey ?? '', () => { - describe('with `activateOnFocus = false`', () => { - it('moves focus to the last tab without activating it if focus is on the first tab', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, , lastTab] = getAllByRole('tab'); - await act(async () => { - firstTab.focus(); + describeSkipIf(isJSDOM && direction === 'rtl')( + `when focus is on a tab element in a ${orientation} ${direction ?? ''} tablist`, + () => { + describe(previousItemKey ?? '', () => { + describe('with `activateOnFocus = false`', () => { + it('moves focus to the last tab without activating it if focus is on the first tab', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, , lastTab] = getAllByRole('tab'); + await act(async () => { + firstTab.focus(); + }); + + fireEvent.keyDown(firstTab, { key: previousItemKey }); + await flushMicrotasks(); + + expect(lastTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - fireEvent.keyDown(firstTab, { key: previousItemKey }); - - expect(lastTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(0); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - }); - - it('moves focus to the previous tab without activating it', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, secondTab] = getAllByRole('tab'); - await act(async () => { - secondTab.focus(); + it('moves focus to the previous tab without activating it', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, secondTab] = getAllByRole('tab'); + await act(async () => { + secondTab.focus(); + }); + + fireEvent.keyDown(secondTab, { key: previousItemKey }); + await flushMicrotasks(); + + expect(firstTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - - fireEvent.keyDown(secondTab, { key: previousItemKey }); - - expect(firstTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(0); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - }); - describe('with `activateOnFocus = true`', () => { - it('moves focus to the last tab while activating it if focus is on the first tab', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, , lastTab] = getAllByRole('tab'); - await act(async () => { - firstTab.focus(); + describe('with `activateOnFocus = true`', () => { + it('moves focus to the last tab while activating it if focus is on the first tab', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, , lastTab] = getAllByRole('tab'); + await act(async () => { + firstTab.focus(); + }); + + fireEvent.keyDown(firstTab, { key: previousItemKey }); + await flushMicrotasks(); + + expect(lastTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0]).to.equal(2); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - fireEvent.keyDown(firstTab, { key: previousItemKey }); - - expect(lastTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(1); - expect(handleChange.firstCall.args[0]).to.equal(2); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - }); - - it('moves focus to the previous tab while activating it', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, secondTab] = getAllByRole('tab'); - await act(async () => { - secondTab.focus(); + it('moves focus to the previous tab while activating it', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, secondTab] = getAllByRole('tab'); + await act(async () => { + secondTab.focus(); + }); + + fireEvent.keyDown(secondTab, { key: previousItemKey }); + await flushMicrotasks(); + + expect(firstTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0]).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - - fireEvent.keyDown(secondTab, { key: previousItemKey }); - - expect(firstTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(1); - expect(handleChange.firstCall.args[0]).to.equal(0); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - }); - }); - - it('skips over disabled tabs', async () => { - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, , lastTab] = getAllByRole('tab'); - await act(async () => { - lastTab.focus(); }); - fireEvent.keyDown(lastTab, { key: previousItemKey }); - - expect(firstTab).toHaveFocus(); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - }); - }); - - describe(nextItemKey ?? '', () => { - describe('with `activateOnFocus = false`', () => { - it('moves focus to the first tab without activating it if focus is on the last tab', async () => { - const handleChange = spy(); + it('skips over disabled tabs', async () => { const handleKeyDown = spy(); const { getAllByRole } = await render( - + - + , @@ -460,141 +436,181 @@ describe('', () => { lastTab.focus(); }); - fireEvent.keyDown(lastTab, { key: nextItemKey }); + fireEvent.keyDown(lastTab, { key: previousItemKey }); + await flushMicrotasks(); expect(firstTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(0); expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); + }); - it('moves focus to the next tab without activating it', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [, secondTab, lastTab] = getAllByRole('tab'); - await act(async () => { - secondTab.focus(); + describe(nextItemKey ?? '', () => { + describe('with `activateOnFocus = false`', () => { + it('moves focus to the first tab without activating it if focus is on the last tab', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, , lastTab] = getAllByRole('tab'); + await act(async () => { + lastTab.focus(); + }); + + fireEvent.keyDown(lastTab, { key: nextItemKey }); + await flushMicrotasks(); + + expect(firstTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - fireEvent.keyDown(secondTab, { key: nextItemKey }); - - expect(lastTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(0); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + it('moves focus to the next tab without activating it', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [, secondTab, lastTab] = getAllByRole('tab'); + await act(async () => { + secondTab.focus(); + }); + + fireEvent.keyDown(secondTab, { key: nextItemKey }); + await flushMicrotasks(); + + expect(lastTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); }); - }); - describe('with `activateOnFocus = true`', () => { - it('moves focus to the first tab while activating it if focus is on the last tab', async () => { - const handleChange = spy(); - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, , lastTab] = getAllByRole('tab'); - await act(async () => { - lastTab.focus(); + describe('with `activateOnFocus = true`', () => { + it('moves focus to the first tab while activating it if focus is on the last tab', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [firstTab, , lastTab] = getAllByRole('tab'); + await act(async () => { + lastTab.focus(); + }); + + fireEvent.keyDown(lastTab, { key: nextItemKey }); + await flushMicrotasks(); + + expect(firstTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0]).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - fireEvent.keyDown(lastTab, { key: nextItemKey }); - - expect(firstTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(1); - expect(handleChange.firstCall.args[0]).to.equal(0); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + it('moves focus to the next tab while activating it', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [, secondTab, lastTab] = getAllByRole('tab'); + await act(async () => { + secondTab.focus(); + }); + + fireEvent.keyDown(secondTab, { key: nextItemKey }); + await flushMicrotasks(); + + expect(lastTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0]).to.equal(2); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); }); - it('moves focus to the next tab while activating it', async () => { - const handleChange = spy(); + it('skips over disabled tabs', async () => { const handleKeyDown = spy(); const { getAllByRole } = await render( - + , ); - const [, secondTab, lastTab] = getAllByRole('tab'); + const [firstTab, , lastTab] = getAllByRole('tab'); await act(async () => { - secondTab.focus(); + firstTab.focus(); }); - fireEvent.keyDown(secondTab, { key: nextItemKey }); + fireEvent.keyDown(firstTab, { key: nextItemKey }); + await flushMicrotasks(); expect(lastTab).toHaveFocus(); - expect(handleChange.callCount).to.equal(1); - expect(handleChange.firstCall.args[0]).to.equal(2); expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); }); - - it('skips over disabled tabs', async () => { - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, , lastTab] = getAllByRole('tab'); - await act(async () => { - firstTab.focus(); - }); - - fireEvent.keyDown(firstTab, { key: nextItemKey }); - - expect(lastTab).toHaveFocus(); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); - }); - }); - }); + }, + ); }); describe('when focus is on a tab regardless of orientation', () => { @@ -617,6 +633,7 @@ describe('', () => { }); fireEvent.keyDown(lastTab, { key: 'Home' }); + await flushMicrotasks(); expect(firstTab).toHaveFocus(); expect(handleChange.callCount).to.equal(0); @@ -642,6 +659,7 @@ describe('', () => { }); fireEvent.keyDown(lastTab, { key: 'Home' }); + await flushMicrotasks(); expect(firstTab).toHaveFocus(); expect(handleChange.callCount).to.equal(1); @@ -667,6 +685,7 @@ describe('', () => { }); fireEvent.keyDown(lastTab, { key: 'Home' }); + await flushMicrotasks(); expect(secondTab).toHaveFocus(); expect(handleKeyDown.callCount).to.equal(1); @@ -693,6 +712,7 @@ describe('', () => { }); fireEvent.keyDown(firstTab, { key: 'End' }); + await flushMicrotasks(); expect(lastTab).toHaveFocus(); expect(handleChange.callCount).to.equal(0); @@ -718,6 +738,7 @@ describe('', () => { }); fireEvent.keyDown(firstTab, { key: 'End' }); + await flushMicrotasks(); expect(lastTab).toHaveFocus(); expect(handleChange.callCount).to.equal(1); @@ -743,6 +764,7 @@ describe('', () => { }); fireEvent.keyDown(firstTab, { key: 'End' }); + await flushMicrotasks(); expect(secondTab).toHaveFocus(); expect(handleKeyDown.callCount).to.equal(1); @@ -768,7 +790,7 @@ describe('', () => { }); }); - describeSkipIf(/jsdom/.test(window.navigator.userAgent))('activation direction', () => { + describeSkipIf(isJSDOM)('activation direction', () => { it('should set the `data-activation-direction` attribute on the tabs root with orientation=horizontal', async () => { const { getAllByRole, getByTestId } = await render( @@ -780,17 +802,17 @@ describe('', () => { ); const root = getByTestId('root'); - const tabs = getAllByRole('tab'); + const [tab1, tab2] = getAllByRole('tab'); expect(root).to.have.attribute('data-activation-direction', 'none'); await act(async () => { - tabs[1].click(); + tab2.click(); }); expect(root).to.have.attribute('data-activation-direction', 'right'); await act(async () => { - tabs[0].click(); + tab1.click(); }); expect(root).to.have.attribute('data-activation-direction', 'left'); @@ -807,17 +829,17 @@ describe('', () => { ); const root = getByTestId('root'); - const tabs = getAllByRole('tab'); + const [tab1, tab2] = getAllByRole('tab'); expect(root).to.have.attribute('data-activation-direction', 'none'); await act(async () => { - tabs[1].click(); + tab2.click(); }); expect(root).to.have.attribute('data-activation-direction', 'down'); await act(async () => { - tabs[0].click(); + tab1.click(); }); expect(root).to.have.attribute('data-activation-direction', 'up'); diff --git a/packages/react/src/Tabs/Root/TabsRoot.tsx b/packages/react/src/Tabs/Root/TabsRoot.tsx index 7f36969908..9c0d233feb 100644 --- a/packages/react/src/Tabs/Root/TabsRoot.tsx +++ b/packages/react/src/Tabs/Root/TabsRoot.tsx @@ -1,11 +1,13 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { useTabsRoot } from './useTabsRoot'; -import { tabsStyleHookMapping } from './styleHooks'; -import { TabsProvider } from './TabsProvider'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import type { BaseUIComponentProps } from '../../utils/types'; +import { CompositeList } from '../../Composite/List/CompositeList'; +import { useTabsRoot } from './useTabsRoot'; +import { TabsRootContext } from './TabsRootContext'; +import { tabsStyleHookMapping } from './styleHooks'; +import { TabPanelMetadata } from '../TabPanel/useTabPanel'; /** * @@ -23,23 +25,59 @@ const TabsRoot = React.forwardRef(function TabsRoot( ) { const { className, - defaultValue, - direction = 'ltr', - onValueChange, + defaultValue = 0, + direction: directionProp = 'ltr', + onValueChange: onValueChangeProp, orientation = 'horizontal', render, - value, + value: valueProp, ...other } = props; - const { contextValue, getRootProps, tabActivationDirection } = useTabsRoot({ + const { + getRootProps, + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + setTabMap, + setTabPanelMap, + tabActivationDirection, + tabPanelRefs, value, + } = useTabsRoot({ + value: valueProp, defaultValue, - onValueChange, - orientation, - direction, + onValueChange: onValueChangeProp, + direction: directionProp, }); + const tabsContextValue: TabsRootContext = React.useMemo( + () => ({ + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + orientation, + setTabMap, + tabActivationDirection, + value, + }), + [ + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + orientation, + setTabMap, + tabActivationDirection, + value, + ], + ); + const ownerState: TabsRoot.OwnerState = { orientation, direction, @@ -56,12 +94,19 @@ const TabsRoot = React.forwardRef(function TabsRoot( customStyleHookMapping: tabsStyleHookMapping, }); - return {renderElement()}; + return ( + + elementsRef={tabPanelRefs} onMapChange={setTabPanelMap}> + {renderElement()} + + + ); }); export type TabsOrientation = 'horizontal' | 'vertical'; export type TabsDirection = 'ltr' | 'rtl'; export type TabActivationDirection = 'left' | 'right' | 'up' | 'down' | 'none'; +export type TabValue = any | null; namespace TabsRoot { export type OwnerState = { @@ -70,16 +115,18 @@ namespace TabsRoot { tabActivationDirection: TabActivationDirection; }; - export interface Props extends BaseUIComponentProps<'div', OwnerState> { + export interface Props extends Omit, 'defaultValue'> { /** - * The value of the currently selected `Tab`. - * If you don't want any selected `Tab`, you can set this prop to `null`. + * The value of the currently selected `Tab`. Use when the component is controlled. + * When the value is `null`, no Tab will be selected. */ - value?: any | null; + value?: TabValue; /** * The default value. Use when the component is not controlled. + * When the value is `null`, no Tab will be selected. + * @default 0 */ - defaultValue?: any | null; + defaultValue?: TabValue; /** * The component orientation (layout flow direction). * @default 'horizontal' @@ -93,10 +140,12 @@ namespace TabsRoot { /** * Callback invoked when new value is being set. */ - onValueChange?: (value: any | null, event?: Event) => void; + onValueChange?: (value: TabValue, event?: Event) => void; } } +export { TabsRoot }; + TabsRoot.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -112,6 +161,8 @@ TabsRoot.propTypes /* remove-proptypes */ = { className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** * The default value. Use when the component is not controlled. + * When the value is `null`, no Tab will be selected. + * @default 0 */ defaultValue: PropTypes.any, /** @@ -133,10 +184,8 @@ TabsRoot.propTypes /* remove-proptypes */ = { */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), /** - * The value of the currently selected `Tab`. - * If you don't want any selected `Tab`, you can set this prop to `null`. + * The value of the currently selected `Tab`. Use when the component is controlled. + * When the value is `null`, no Tab will be selected. */ value: PropTypes.any, } as any; - -export { TabsRoot }; diff --git a/packages/react/src/Tabs/Root/TabsRootContext.ts b/packages/react/src/Tabs/Root/TabsRootContext.ts index 5e1e5e30da..5797e146d9 100644 --- a/packages/react/src/Tabs/Root/TabsRootContext.ts +++ b/packages/react/src/Tabs/Root/TabsRootContext.ts @@ -1,19 +1,20 @@ 'use client'; import * as React from 'react'; -import { type TabActivationDirection } from './TabsRoot'; +import { type TabMetadata } from '../Tab/useTab'; +import type { TabActivationDirection, TabValue } from './TabsRoot'; export interface TabsRootContext { /** * The currently selected tab's value. */ - value?: any | null; + value: TabValue; /** * Callback for setting new value. */ - onSelected: ( - event: Event | undefined, - value: any | null, + onValueChange: ( + value: TabValue, activationDirection: TabActivationDirection, + event: Event, ) => void; /** * The component orientation (layout flow direction). @@ -24,19 +25,26 @@ export interface TabsRootContext { */ direction: 'ltr' | 'rtl'; /** - * Registers a function that returns the id of the tab with the given value. + * Gets the element of the Tab with the given value. + * @param {any | undefined} value Value to find the tab for. */ - registerTabIdLookup: (lookupFunction: (id: any) => string | undefined) => void; + getTabElementBySelectedValue: (selectedValue: TabValue | undefined) => HTMLElement | null; /** - * Gets the id of the tab with the given value. - * @param value Value to find the tab for. + * Gets the `id` attribute of the Tab that corresponds to the given TabPanel value or index. + * @param (any | undefined) panelValue Value to find the Tab for. + * @param (number) index The index of the TabPanel to look for. */ - getTabId: (value: any) => string | undefined; + getTabIdByPanelValueOrIndex: ( + panelValue: TabValue | undefined, + index: number, + ) => string | undefined; /** - * Gets the id of the tab panel with the given value. - * @param value Value to find the tab panel for. + * Gets the `id` attribute of the TabPanel that corresponds to the given Tab value or index. + * @param (any | undefined) tabValue Value to find the Tab for. + * @param (number) index The index of the Tab to look for. */ - getTabPanelId: (value: any) => string | undefined; + getTabPanelIdByTabValueOrIndex: (tabValue: any, index: number) => string | undefined; + setTabMap: (map: Map) => void; /** * The position of the active tab relative to the previously active tab. */ diff --git a/packages/react/src/Tabs/Root/useTabsRoot.ts b/packages/react/src/Tabs/Root/useTabsRoot.ts index 1a38f6e8f9..1594ffb5dc 100644 --- a/packages/react/src/Tabs/Root/useTabsRoot.ts +++ b/packages/react/src/Tabs/Root/useTabsRoot.ts @@ -1,28 +1,23 @@ 'use client'; import * as React from 'react'; -import type { TabActivationDirection } from './TabsRoot'; -import type { TabsProviderValue, TabPanelMetadata } from './TabsProvider'; -import { useCompoundParent } from '../../useCompound'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { GenericHTMLProps } from '../../utils/types'; import { useControlled } from '../../utils/useControlled'; - -export interface TabMetadata { - disabled: boolean; - id: string | undefined; - ref: React.RefObject; -} - -type IdLookupFunction = (id: any) => string | undefined; +import type { CompositeMetadata } from '../../Composite/List/CompositeList'; +import type { TabPanelMetadata } from '../TabPanel/useTabPanel'; +import type { TabMetadata } from '../Tab/useTab'; +import type { TabActivationDirection, TabValue } from './TabsRoot'; function useTabsRoot(parameters: useTabsRoot.Parameters): useTabsRoot.ReturnValue { const { value: valueProp, defaultValue, - onValueChange, - orientation = 'horizontal', + onValueChange: onValueChangeProp, direction = 'ltr', } = parameters; + const tabPanelRefs = React.useRef<(HTMLElement | null)[]>([]); + const [value, setValue] = useControlled({ controlled: valueProp, default: defaultValue, @@ -30,43 +25,102 @@ function useTabsRoot(parameters: useTabsRoot.Parameters): useTabsRoot.ReturnValu state: 'value', }); + const [tabPanelMap, setTabPanelMap] = React.useState( + () => new Map | null>(), + ); + const [tabMap, setTabMap] = React.useState( + () => new Map | null>(), + ); + const [tabActivationDirection, setTabActivationDirection] = React.useState('none'); - const onSelected = React.useCallback( - ( - event: Event | undefined, - newValue: any | null, - activationDirection: TabActivationDirection, - ) => { + const onValueChange = React.useCallback( + (newValue: TabValue, activationDirection: TabActivationDirection, event: Event | undefined) => { setValue(newValue); setTabActivationDirection(activationDirection); - onValueChange?.(newValue, event ?? undefined); + onValueChangeProp?.(newValue, event); }, - [onValueChange, setValue], + [onValueChangeProp, setValue], ); - const { subitems: tabPanels, contextValue: compoundComponentContextValue } = useCompoundParent< - any, - TabPanelMetadata - >(); - - const tabIdLookup = React.useRef(() => undefined); + // get the `id` attribute of to set as the value of `aria-controls` on + const getTabPanelIdByTabValueOrIndex = React.useCallback( + (tabValue: TabValue | undefined, index: number) => { + if (tabValue === undefined && index < 0) { + return undefined; + } + + for (const tabPanelMetadata of tabPanelMap.values()) { + // find by tabValue + if (tabValue !== undefined && tabPanelMetadata && tabValue === tabPanelMetadata?.value) { + return tabPanelMetadata.id; + } + + // find by index + if ( + tabValue === undefined && + tabPanelMetadata?.index && + tabPanelMetadata?.index === index + ) { + return tabPanelMetadata.id; + } + } + + return undefined; + }, + [tabPanelMap], + ); - const getTabPanelId = React.useCallback( - (tabValue: any) => { - return tabPanels.get(tabValue)?.id; + // get the `id` attribute of to set as the value of `aria-labelledby` on + const getTabIdByPanelValueOrIndex = React.useCallback( + (tabPanelValue: TabValue | undefined, index: number) => { + if (tabPanelValue === undefined && index < 0) { + return undefined; + } + + for (const tabMetadata of tabMap.values()) { + // find by tabPanelValue + if ( + tabPanelValue !== undefined && + index > -1 && + tabPanelValue === (tabMetadata?.value ?? tabMetadata?.index ?? undefined) + ) { + return tabMetadata?.id; + } + + // find by index + if ( + tabPanelValue === undefined && + index > -1 && + index === (tabMetadata?.value ?? tabMetadata?.index ?? undefined) + ) { + return tabMetadata?.id; + } + } + + return undefined; }, - [tabPanels], + [tabMap], ); - const getTabId = React.useCallback((tabPanelId: any) => { - return tabIdLookup.current(tabPanelId); - }, []); + // used in `useActivationDirectionDetector` for setting data-activation-direction + const getTabElementBySelectedValue = React.useCallback( + (selectedValue: TabValue | undefined): HTMLElement | null => { + if (selectedValue === undefined) { + return null; + } - const registerTabIdLookup = React.useCallback((lookupFunction: IdLookupFunction) => { - tabIdLookup.current = lookupFunction; - }, []); + for (const [tabElement, tabMetadata] of tabMap.entries()) { + if (tabMetadata != null && selectedValue === (tabMetadata.value ?? tabMetadata.index)) { + return tabElement as HTMLElement; + } + } + + return null; + }, + [tabMap], + ); const getRootProps: useTabsRoot.ReturnValue['getRootProps'] = React.useCallback( (otherProps = {}) => @@ -77,19 +131,18 @@ function useTabsRoot(parameters: useTabsRoot.Parameters): useTabsRoot.ReturnValu ); return { - contextValue: { - direction, - getTabId, - getTabPanelId, - onSelected, - orientation, - registerTabIdLookup, - value, - tabActivationDirection, - ...compoundComponentContextValue, - }, getRootProps, + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + setTabMap, + setTabPanelMap, tabActivationDirection, + tabMap, + tabPanelRefs, + value, }; } @@ -99,16 +152,11 @@ namespace useTabsRoot { * The value of the currently selected `Tab`. * If you don't want any selected `Tab`, you can set this prop to `false`. */ - value?: any | null; + value?: TabValue; /** * The default value. Use when the component is not controlled. */ - defaultValue?: any | null; - /** - * The component orientation (layout flow direction). - * @default 'horizontal' - */ - orientation?: 'horizontal' | 'vertical'; + defaultValue?: TabValue; /** * The direction of the text. * @default 'ltr' @@ -117,19 +165,63 @@ namespace useTabsRoot { /** * Callback invoked when new value is being set. */ - onValueChange?: (value: any | null, event?: Event) => void; + onValueChange?: (value: TabValue, event?: Event) => void; } export interface ReturnValue { /** - * Returns the values to be passed to the tabs provider. + * Resolver for the Root component's props. + * @param externalProps additional props for Tabs.Root + * @returns props that should be spread on Tabs.Root + */ + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * The direction of the text. + */ + direction: 'ltr' | 'rtl'; + /** + * Gets the element of the Tab with the given value. + * @param {any | undefined} value Value to find the tab for. + */ + getTabElementBySelectedValue: (panelValue: TabValue | undefined) => HTMLElement | null; + /** + * Gets the `id` attribute of the Tab that corresponds to the given TabPanel value or index. + * @param (any | undefined) panelValue Value to find the Tab for. + * @param (number) index The index of the Tab to look for. + */ + getTabIdByPanelValueOrIndex: ( + panelValue: TabValue | undefined, + index: number, + ) => string | undefined; + /** + * Gets the `id` attribute of the TabPanel that corresponds to the given Tab value or index. + * @param value Value to find the tab panel for. + */ + getTabPanelIdByTabValueOrIndex: ( + tabValue: TabValue | undefined, + index: number, + ) => string | undefined; + /** + * Callback for setting new value. + */ + onValueChange: ( + value: TabValue, + activationDirection: TabActivationDirection, + event: Event, + ) => void; + setTabMap: (map: Map | null>) => void; + setTabPanelMap: (map: Map | null>) => void; + /** + * The position of the active tab relative to the previously active tab. */ - contextValue: TabsProviderValue; - getRootProps: ( - externalProps?: React.ComponentPropsWithRef<'div'>, - ) => React.ComponentPropsWithRef<'div'>; tabActivationDirection: TabActivationDirection; + tabMap: Map | null>; + tabPanelRefs: React.RefObject<(HTMLElement | null)[]>; + /** + * The currently selected tab's value. + */ + value: TabValue; } } -export { useTabsRoot }; +export { useTabsRoot, TabMetadata }; diff --git a/packages/react/src/Tabs/Tab/Tab.test.tsx b/packages/react/src/Tabs/Tab/Tab.test.tsx index 257e289b41..010ae35c4c 100644 --- a/packages/react/src/Tabs/Tab/Tab.test.tsx +++ b/packages/react/src/Tabs/Tab/Tab.test.tsx @@ -1,22 +1,24 @@ import * as React from 'react'; import { Tabs } from '@base-ui-components/react/Tabs'; import { createRenderer, describeConformance } from '#test-utils'; -import { TabsListProviderValue, TabsListProvider } from '../TabsList/TabsListProvider'; +import { NOOP } from '../../utils/noop'; +import { CompositeRootContext } from '../../Composite/Root/CompositeRootContext'; import { TabsRootContext } from '../Root/TabsRootContext'; +import { TabsListContext } from '../TabsList/TabsListContext'; describe('', () => { const { render } = createRenderer(); - const testTabsListContext: TabsListProviderValue = { - dispatch: () => {}, - registerItem: () => ({ id: 0, deregister: () => {} }), - getItemIndex: () => 0, - totalSubitemCount: 1, - getItemState() { - return { disabled: false, highlighted: false, selected: false, focusable: true, index: 0 }; - }, + const testCompositeContext = { + highlightedIndex: 0, + onHighlightedIndexChange: NOOP, + }; + + const testTabsListContext: TabsListContext = { activateOnFocus: true, - getTabElement: () => null, + highlightedTabIndex: 0, + onTabActivation: NOOP, + setHighlightedTabIndex: NOOP, tabsListRef: { current: null, }, @@ -24,10 +26,11 @@ describe('', () => { const testTabsContext: TabsRootContext = { value: 0, - onSelected() {}, - registerTabIdLookup() {}, - getTabId: () => '', - getTabPanelId: () => '', + onValueChange() {}, + setTabMap() {}, + getTabElementBySelectedValue: () => null, + getTabIdByPanelValueOrIndex: () => '', + getTabPanelIdByTabValueOrIndex: () => '', orientation: 'horizontal', direction: 'ltr', tabActivationDirection: 'none', @@ -37,7 +40,11 @@ describe('', () => { render: (node) => { return render( - {node} + + + {node} + + , ); }, diff --git a/packages/react/src/Tabs/Tab/Tab.tsx b/packages/react/src/Tabs/Tab/Tab.tsx index d6c007657b..3ea7a9d96c 100644 --- a/packages/react/src/Tabs/Tab/Tab.tsx +++ b/packages/react/src/Tabs/Tab/Tab.tsx @@ -4,7 +4,9 @@ import PropTypes from 'prop-types'; import { useTab } from './useTab'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import type { BaseUIComponentProps } from '../../utils/types'; -import { TabsOrientation } from '../Root/TabsRoot'; +import type { TabsOrientation, TabValue } from '../Root/TabsRoot'; +import { useTabsRootContext } from '../Root/TabsRootContext'; +import { useTabsListContext } from '../TabsList/TabsListContext'; /** * @@ -20,18 +22,41 @@ const Tab = React.forwardRef(function Tab( props: Tab.Props, forwardedRef: React.ForwardedRef, ) { - const { className, disabled = false, render, value, ...other } = props; + const { className, disabled = false, render, value: valueProp, id: idProp, ...other } = props; - const { selected, getRootProps, orientation } = useTab({ - ...props, + const { + value: selectedTabValue, + getTabPanelIdByTabValueOrIndex, + orientation, + } = useTabsRootContext(); + + const { activateOnFocus, highlightedTabIndex, onTabActivation, setHighlightedTabIndex } = + useTabsListContext(); + + const { getRootProps, index, selected } = useTab({ + activateOnFocus, + disabled, + getTabPanelIdByTabValueOrIndex, + highlightedTabIndex, + id: idProp, + onTabActivation, rootRef: forwardedRef, + setHighlightedTabIndex, + selectedTabValue, + value: valueProp, }); - const ownerState: Tab.OwnerState = { - disabled, - selected, - orientation, - }; + const highlighted = index > -1 && index === highlightedTabIndex; + + const ownerState: Tab.OwnerState = React.useMemo( + () => ({ + disabled, + highlighted, + selected, + orientation, + }), + [disabled, highlighted, selected, orientation], + ); const { renderElement } = useComponentRenderer({ propGetter: getRootProps, @@ -47,9 +72,10 @@ const Tab = React.forwardRef(function Tab( namespace Tab { export interface Props extends BaseUIComponentProps<'button', Tab.OwnerState> { /** - * You can provide your own value. Otherwise, it falls back to the child position index. + * The value of the Tab. + * When not specified, the value is the child position index. */ - value?: any; + value?: TabValue; } export interface OwnerState { @@ -59,6 +85,8 @@ namespace Tab { } } +export { Tab }; + Tab.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -76,14 +104,17 @@ Tab.propTypes /* remove-proptypes */ = { * @ignore */ disabled: PropTypes.bool, + /** + * @ignore + */ + id: PropTypes.string, /** * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), /** - * You can provide your own value. Otherwise, it falls back to the child position index. + * The value of the Tab. + * When not specified, the value is the child position index. */ value: PropTypes.any, } as any; - -export { Tab }; diff --git a/packages/react/src/Tabs/Tab/useTab.test.tsx b/packages/react/src/Tabs/Tab/useTab.test.tsx deleted file mode 100644 index 13feef62a9..0000000000 --- a/packages/react/src/Tabs/Tab/useTab.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from 'react'; -import { expect } from 'chai'; -import { spy } from 'sinon'; -import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; -import { Tabs } from '@base-ui-components/react/Tabs'; -import { useTab } from './useTab'; - -describe('useTab', () => { - const { render } = createRenderer(); - - describe('getRootProps', () => { - it('returns props for root slot', () => { - function TestTab() { - const rootRef = React.createRef(); - const { getRootProps } = useTab({ rootRef }); - return