@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
JavaScript
;
'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';
}