UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

275 lines (266 loc) 10.4 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.TabsRoot = void 0; var React = _interopRequireWildcard(require("react")); var _useControlled = require("@base-ui/utils/useControlled"); var _useIsoLayoutEffect = require("@base-ui/utils/useIsoLayoutEffect"); var _useStableCallback = require("@base-ui/utils/useStableCallback"); var _useRenderElement = require("../../internals/useRenderElement"); var _CompositeList = require("../../internals/composite/list/CompositeList"); var _TabsRootContext = require("./TabsRootContext"); var _stateAttributesMapping = require("./stateAttributesMapping"); var _jsxRuntime = require("react/jsx-runtime"); /** * Groups the tabs and the corresponding panels. * Renders a `<div>` element. * * Documentation: [Base UI Tabs](https://base-ui.com/react/components/tabs) */ const TabsRoot = exports.TabsRoot = /*#__PURE__*/React.forwardRef(function TabsRoot(componentProps, forwardedRef) { const { className, defaultValue: defaultValueProp = 0, onValueChange: onValueChangeProp, orientation = 'horizontal', render, value: valueProp, style, ...elementProps } = componentProps; // Track whether the user explicitly provided a `defaultValue` prop. // Used to determine if we should honor a disabled tab selection. const hasExplicitDefaultValueProp = Object.hasOwn(componentProps, 'defaultValue'); const tabPanelRefs = React.useRef([]); const [mountedTabPanels, setMountedTabPanels] = React.useState(() => new Map()); const [value, setValue] = (0, _useControlled.useControlled)({ controlled: valueProp, default: defaultValueProp, name: 'Tabs', state: 'value' }); const isControlled = valueProp !== undefined; const [tabMap, setTabMap] = React.useState(() => new Map()); // Used for activation direction detection via tab element positions. const getTabElementBySelectedValue = React.useCallback(selectedValue => { if (selectedValue === undefined) { return null; } for (const [tabElement, tabMetadata] of tabMap.entries()) { if (tabMetadata != null && selectedValue === (tabMetadata.value ?? tabMetadata.index)) { return tabElement; } } return null; }, [tabMap]); const [activationDirectionState, setActivationDirectionState] = React.useState(() => ({ previousValue: value, tabActivationDirection: 'none' })); const { previousValue, tabActivationDirection: committedTabActivationDirection } = activationDirectionState; let tabActivationDirection = committedTabActivationDirection; let directionComputationIncomplete = false; // Compute activation direction during render when value changes so children see // the correct direction on their very first render after the selection update. // The previous value snapshot is stored in state and synced after commit. // https://github.com/mui/base-ui/issues/3873 if (previousValue !== value) { tabActivationDirection = computeActivationDirection(previousValue, value, orientation, tabMap); // When a new tab is added and selected in the same controlled update, // the tab element may not yet be registered in tabMap, so direction was // computed from a value-based fallback. Keep the previous value snapshot // stale so we re-compute from DOM positions once tabMap is up to date. directionComputationIncomplete = previousValue != null && value != null && getTabElementBySelectedValue(value) == null; } const nextPreviousValue = directionComputationIncomplete ? previousValue : value; const shouldSyncActivationDirectionState = previousValue !== nextPreviousValue || committedTabActivationDirection !== tabActivationDirection; (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (!shouldSyncActivationDirectionState) { return; } setActivationDirectionState({ previousValue: nextPreviousValue, tabActivationDirection }); }, [nextPreviousValue, shouldSyncActivationDirectionState, tabActivationDirection]); const onValueChange = (0, _useStableCallback.useStableCallback)((newValue, eventDetails) => { const activationDirection = computeActivationDirection(value, newValue, orientation, tabMap); eventDetails.activationDirection = activationDirection; onValueChangeProp?.(newValue, eventDetails); if (eventDetails.isCanceled) { return; } setValue(newValue); }); const registerMountedTabPanel = (0, _useStableCallback.useStableCallback)((panelValue, panelId) => { setMountedTabPanels(prev => { if (prev.get(panelValue) === panelId) { return prev; } const next = new Map(prev); next.set(panelValue, panelId); return next; }); }); const unregisterMountedTabPanel = (0, _useStableCallback.useStableCallback)((panelValue, panelId) => { setMountedTabPanels(prev => { if (!prev.has(panelValue) || prev.get(panelValue) !== panelId) { return prev; } const next = new Map(prev); next.delete(panelValue); return next; }); }); // get the `id` attribute of <Tabs.Panel> to set as the value of `aria-controls` on <Tabs.Tab> const getTabPanelIdByValue = React.useCallback(tabValue => { return mountedTabPanels.get(tabValue); }, [mountedTabPanels]); // get the `id` attribute of <Tabs.Tab> to set as the value of `aria-labelledby` on <Tabs.Panel> const getTabIdByPanelValue = React.useCallback(tabPanelValue => { for (const tabMetadata of tabMap.values()) { if (tabPanelValue === tabMetadata?.value) { return tabMetadata?.id; } } return undefined; }, [tabMap]); const tabsContextValue = React.useMemo(() => ({ getTabElementBySelectedValue, getTabIdByPanelValue, getTabPanelIdByValue, onValueChange, orientation, registerMountedTabPanel, setTabMap, unregisterMountedTabPanel, tabActivationDirection, value }), [getTabElementBySelectedValue, getTabIdByPanelValue, getTabPanelIdByValue, onValueChange, orientation, registerMountedTabPanel, setTabMap, unregisterMountedTabPanel, tabActivationDirection, value]); const selectedTabMetadata = React.useMemo(() => { for (const tabMetadata of tabMap.values()) { if (tabMetadata != null && tabMetadata.value === value) { return tabMetadata; } } return undefined; }, [tabMap, value]); // Find the first non-disabled tab value. // Used as a fallback when the current selection is disabled or missing. const firstEnabledTabValue = React.useMemo(() => { for (const tabMetadata of tabMap.values()) { if (tabMetadata != null && !tabMetadata.disabled) { return tabMetadata.value; } } return undefined; }, [tabMap]); // Automatically switch to the first enabled tab when: // - The current selection is disabled (and wasn't explicitly set via defaultValue) // - The current selection is missing (tab was removed from DOM) // Falls back to null if all tabs are disabled. (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (isControlled || tabMap.size === 0) { return; } const selectionIsDisabled = selectedTabMetadata?.disabled; const selectionIsMissing = selectedTabMetadata == null && value !== null; const shouldHonorExplicitDefaultSelection = hasExplicitDefaultValueProp && selectionIsDisabled && value === defaultValueProp; if (shouldHonorExplicitDefaultSelection) { return; } if (!selectionIsDisabled && !selectionIsMissing) { return; } const fallbackValue = firstEnabledTabValue ?? null; if (value === fallbackValue) { return; } setValue(fallbackValue); setActivationDirectionState(prev => { if (prev.tabActivationDirection === 'none') { return prev; } return { ...prev, tabActivationDirection: 'none' }; }); }, [defaultValueProp, firstEnabledTabValue, hasExplicitDefaultValueProp, isControlled, selectedTabMetadata, setValue, tabMap, value]); const state = { orientation, tabActivationDirection }; const element = (0, _useRenderElement.useRenderElement)('div', componentProps, { state, ref: forwardedRef, props: elementProps, stateAttributesMapping: _stateAttributesMapping.tabsStateAttributesMapping }); return /*#__PURE__*/(0, _jsxRuntime.jsx)(_TabsRootContext.TabsRootContext.Provider, { value: tabsContextValue, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_CompositeList.CompositeList, { elementsRef: tabPanelRefs, children: element }) }); }); if (process.env.NODE_ENV !== "production") TabsRoot.displayName = "TabsRoot"; function computeActivationDirection(oldValue, newValue, orientation, tabMap) { if (oldValue == null || newValue == null) { return 'none'; } let oldTab = null; let newTab = null; for (const [tabElement, tabMetadata] of tabMap.entries()) { if (tabMetadata == null) { continue; } const tabValue = tabMetadata.value ?? tabMetadata.index; if (oldValue === tabValue) { oldTab = tabElement; } if (newValue === tabValue) { newTab = tabElement; } if (oldTab != null && newTab != null) { break; } } if (oldTab == null || newTab == null) { // Fallback for dynamic tabs: when a tab element isn't registered yet // (e.g. added and selected in the same update), infer direction from // the values themselves. Works for comparable types (numbers, strings). if (oldTab !== newTab && (typeof oldValue === 'number' || typeof oldValue === 'string') && typeof oldValue === typeof newValue) { if (orientation === 'horizontal') { return newValue > oldValue ? 'right' : 'left'; } return newValue > oldValue ? 'down' : 'up'; } return 'none'; } const oldRect = oldTab.getBoundingClientRect(); const newRect = newTab.getBoundingClientRect(); if (orientation === 'horizontal') { if (newRect.left < oldRect.left) { return 'left'; } if (newRect.left > oldRect.left) { return 'right'; } } else { if (newRect.top < oldRect.top) { return 'up'; } if (newRect.top > oldRect.top) { return 'down'; } } return 'none'; }