@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.
322 lines (312 loc) • 13 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 _createBaseUIEventDetails = require("../../internals/createBaseUIEventDetails");
var _reasons = require("../../internals/reasons");
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 defined `defaultValue` prop.
// Used to determine if we should honor a disabled tab selection.
const hasExplicitDefaultValueProp = componentProps.defaultValue !== undefined;
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 notifyAutomaticValueChange = (0, _useStableCallback.useStableCallback)((nextValue, reason) => {
onValueChangeProp?.(nextValue, (0, _createBaseUIEventDetails.createChangeEventDetails)(reason, undefined, undefined, {
activationDirection: 'none'
}));
});
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]);
// Implicit uncontrolled selections are still automatic changes, so notify
// once when the tabs first register. Explicit defaults are treated as user-owned.
const shouldNotifyInitialValueChangeRef = React.useRef(!hasExplicitDefaultValueProp);
// An explicit defaultValue can intentionally point at a disabled tab on mount.
// Once that selection becomes valid, later disabled states should fall back.
const shouldHonorDisabledDefaultValueRef = React.useRef(hasExplicitDefaultValueProp);
const didRegisterTabsRef = React.useRef(false);
// Uncontrolled roots own automatic fallback. Controlled roots keep the exact
// value supplied by the parent, even when that tab is disabled or missing.
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (isControlled) {
return;
}
function commitAutomaticValueChange(fallbackValue, fallbackReason) {
setValue(fallbackValue);
// Automatic fallbacks are not directional transitions; reset the direction
// alongside the value so the batched commit keeps both in sync.
setActivationDirectionState(prev => {
if (prev.previousValue === fallbackValue && prev.tabActivationDirection === 'none') {
return prev;
}
return {
previousValue: fallbackValue,
tabActivationDirection: 'none'
};
});
notifyAutomaticValueChange(fallbackValue, fallbackReason);
// Mark the initial notification as delivered only after the consumer
// callback returns. The fallback value is queued first so automatic
// consistency updates are not cancelable through a throwing handler.
shouldNotifyInitialValueChangeRef.current = false;
}
if (tabMap.size === 0) {
if (!didRegisterTabsRef.current || value === null) {
return;
}
commitAutomaticValueChange(null, _reasons.REASONS.missing);
return;
}
didRegisterTabsRef.current = true;
const selectionIsDisabled = selectedTabMetadata?.disabled;
const selectionIsMissing = selectedTabMetadata == null && value !== null;
if (!selectionIsDisabled && value === defaultValueProp) {
shouldHonorDisabledDefaultValueRef.current = false;
}
if (shouldHonorDisabledDefaultValueRef.current && selectionIsDisabled && value === defaultValueProp) {
return;
}
const shouldNotifyInitialValueChange = shouldNotifyInitialValueChangeRef.current;
if (selectionIsDisabled || selectionIsMissing) {
const fallbackValue = firstEnabledTabValue ?? null;
if (value === fallbackValue) {
// Already at the fallback value; no commit or notification needed,
// but record that the implicit-initial transition has resolved.
shouldNotifyInitialValueChangeRef.current = false;
return;
}
let fallbackReason = _reasons.REASONS.missing;
if (shouldNotifyInitialValueChange) {
fallbackReason = _reasons.REASONS.initial;
} else if (selectionIsDisabled) {
fallbackReason = _reasons.REASONS.disabled;
}
commitAutomaticValueChange(fallbackValue, fallbackReason);
return;
}
if (shouldNotifyInitialValueChange && selectedTabMetadata != null) {
notifyAutomaticValueChange(value, _reasons.REASONS.initial);
shouldNotifyInitialValueChangeRef.current = false;
}
}, [defaultValueProp, firstEnabledTabValue, isControlled, notifyAutomaticValueChange, 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';
}