@carbon/react
Version:
React components for the Carbon Design System
1,191 lines (1,146 loc) • 39.6 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import { Close, ChevronLeft, ChevronRight } from '@carbon/icons-react';
import { breakpoints } from '@carbon/layout';
import cx from 'classnames';
import PropTypes from 'prop-types';
import React, { forwardRef, createContext, useRef, useState, useMemo, isValidElement, Children, cloneElement, useCallback, useEffect } from 'react';
import '../Grid/FlexGrid.js';
import { Grid as GridAsGridComponent } from '../Grid/Grid.js';
import '../Grid/Row.js';
import '../Grid/Column.js';
import '../Grid/ColumnHang.js';
import '../Grid/GridContext.js';
import '../Tooltip/DefinitionTooltip.js';
import { Tooltip } from '../Tooltip/Tooltip.js';
import { useControllableState } from '../../internal/useControllableState.js';
import { useId } from '../../internal/useId.js';
import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js';
import { useMergedRefs } from '../../internal/useMergedRefs.js';
import { usePrefix } from '../../internal/usePrefix.js';
import { Delete, ArrowRight, ArrowLeft, Home, End, ArrowDown, ArrowUp } from '../../internal/keyboard/keys.js';
import { match, matches } from '../../internal/keyboard/match.js';
import { usePressable } from './usePressable.js';
import { deprecate } from '../../prop-types/deprecate.js';
import { useEvent } from '../../internal/useEvent.js';
import { useMatchMedia } from '../../internal/useMatchMedia.js';
import { Text } from '../Text/Text.js';
import '../Text/TextDirection.js';
import { BadgeIndicator } from '../BadgeIndicator/index.js';
import { isComponentElement } from '../../internal/utils.js';
import { debounce } from '../../node_modules/es-toolkit/dist/compat/function/debounce.js';
var _ChevronLeft, _ChevronRight, _BadgeIndicator;
const verticalTabHeight = 64;
// Used to manage the overall state of the Tabs
const TabsContext = /*#__PURE__*/React.createContext({
baseId: '',
activeIndex: 0,
defaultSelectedIndex: 0,
dismissable: false,
onTabCloseRequest() {},
setActiveIndex() {},
selectedIndex: 0,
setSelectedIndex() {}
});
// Used to keep track of position in a tablist
const TabContext = /*#__PURE__*/React.createContext({
index: 0,
hasSecondaryLabel: false
});
const lgMediaQuery = `(min-width: ${breakpoints.lg.width})`;
const smMediaQuery = `(max-width: ${breakpoints.md.width})`;
// Used to keep track of position in a list of tab panels
const TabPanelContext = /*#__PURE__*/React.createContext(0);
/**
* Tabs
*/
function Tabs({
children,
defaultSelectedIndex = 0,
onChange,
selectedIndex: controlledSelectedIndex,
dismissable,
onTabCloseRequest
}) {
const baseId = useId('ccs');
if (dismissable && !onTabCloseRequest) {
// eslint-disable-next-line no-console -- https://github.com/carbon-design-system/carbon/issues/20452
console.error('dismissable property specified without also providing an onTabCloseRequest property.');
}
// The active index is used to track the element which has focus in our tablist
const [activeIndex, setActiveIndex] = useState(defaultSelectedIndex);
// The selected index is used for the tab/panel pairing which is "visible"
const [selectedIndex, setSelectedIndex] = useControllableState({
value: controlledSelectedIndex,
defaultValue: defaultSelectedIndex,
onChange: value => onChange?.({
selectedIndex: value
})
});
const value = {
baseId,
activeIndex,
defaultSelectedIndex,
dismissable,
onTabCloseRequest,
setActiveIndex,
selectedIndex,
setSelectedIndex
};
return /*#__PURE__*/React.createElement(TabsContext.Provider, {
value: value
}, children);
}
Tabs.propTypes = {
/**
* Provide child elements to be rendered inside the `Tabs`.
* These elements should render either `TabsList` or `TabsPanels`
*/
children: PropTypes.node,
/**
* Specify which content tab should be initially selected when the component
* is first rendered
*/
defaultSelectedIndex: PropTypes.number,
/**
* Whether the render Tab children should be dismissable.
*/
dismissable: PropTypes.bool,
/**
* Provide an optional function which is called whenever the state of the
* `Tabs` changes
*/
onChange: PropTypes.func,
/**
* If specifying the `onTabCloseRequest` prop, provide a callback function
* responsible for removing the tab when close button is pressed on one of the Tab elements
*/
onTabCloseRequest: props => {
if (props.dismissable && !props.onTabCloseRequest) {
return new Error('dismissable property specified without also providing an onTabCloseRequest property.');
}
return undefined;
},
/**
* Control which content panel is currently selected. This puts the component
* in a controlled mode and should be used along with `onChange`
*/
selectedIndex: PropTypes.number
};
function TabsVertical({
children,
height,
defaultSelectedIndex = 0,
onChange,
selectedIndex: controlledSelectedIndex,
...rest
}) {
const [selectedIndex, setSelectedIndex] = useControllableState({
value: controlledSelectedIndex,
defaultValue: defaultSelectedIndex,
onChange: value => onChange?.({
selectedIndex: value
})
});
const props = {
...rest,
selectedIndex,
onChange: ({
selectedIndex
}) => setSelectedIndex(selectedIndex)
};
const isSm = useMatchMedia(smMediaQuery);
if (!isSm) {
return /*#__PURE__*/React.createElement(GridAsGridComponent, {
style: {
height: height
}
}, /*#__PURE__*/React.createElement(Tabs, props, children));
}
return /*#__PURE__*/React.createElement(Tabs, props, children);
}
TabsVertical.propTypes = {
/**
* Provide child elements to be rendered inside the `TabsVertical`.
* These elements should render either `TabsListVertical` or `TabsPanels`
*/
children: PropTypes.node,
/**
* Specify which content tab should be initially selected when the component
* is first rendered
*/
defaultSelectedIndex: PropTypes.number,
/**
* Option to set a height style only if using vertical variation
*/
height: PropTypes.string,
/**
* Provide an optional function which is called whenever the state of the
* `Tabs` changes
*/
onChange: PropTypes.func,
/**
* Control which content panel is currently selected. This puts the component
* in a controlled mode and should be used along with `onChange`
*/
selectedIndex: PropTypes.number
};
/**
* Get the next index for a given keyboard event
* given a count of the total items and the current index
*/
function getNextIndex(event, total, index) {
switch (true) {
case match(event, ArrowRight):
return (index + 1) % total;
case match(event, ArrowLeft):
return (total + index - 1) % total;
case match(event, Home):
return 0;
case match(event, End):
return total - 1;
default:
return index;
}
}
/**
* Get the next index for a given keyboard event
* given a count of the total items and the current index
*/
function getNextIndexVertical(event, total, index) {
switch (true) {
case match(event, ArrowDown):
return (index + 1) % total;
case match(event, ArrowUp):
return (total + index - 1) % total;
case match(event, Home):
return 0;
case match(event, End):
return total - 1;
default:
return index;
}
}
/**
* TabList
*/
function TabList({
activation = 'automatic',
'aria-label': label,
children,
className: customClassName,
contained = false,
fullWidth = false,
iconSize,
leftOverflowButtonProps,
light,
rightOverflowButtonProps,
scrollDebounceWait = 200,
scrollIntoView,
...rest
}) {
const {
activeIndex,
selectedIndex,
setSelectedIndex,
setActiveIndex,
dismissable
} = React.useContext(TabsContext);
const prefix = usePrefix();
const ref = useRef(null);
const previousButton = useRef(null);
const nextButton = useRef(null);
const [isScrollable, setIsScrollable] = useState(false);
const [scrollLeft, setScrollLeft] = useState(0);
const hasSecondaryLabelTabs = contained && Children.toArray(children).some(child => isComponentElement(child, Tab) && typeof child.props.secondaryLabel !== 'undefined');
const isLg = useMatchMedia(lgMediaQuery);
const distributeWidth = fullWidth && contained && isLg && React.Children.toArray(children).length < 9;
const className = cx(`${prefix}--tabs`, {
[`${prefix}--tabs--contained`]: contained,
[`${prefix}--tabs--light`]: light,
[`${prefix}--tabs__icon--default`]: iconSize === 'default',
[`${prefix}--tabs__icon--lg`]: iconSize === 'lg',
// TODO: V12 - Remove this class
[`${prefix}--layout--size-lg`]: iconSize === 'lg',
[`${prefix}--tabs--tall`]: hasSecondaryLabelTabs,
[`${prefix}--tabs--full-width`]: distributeWidth,
[`${prefix}--tabs--dismissable`]: dismissable
}, customClassName);
// Previous Button
// VISIBLE IF:
// SCROLLABLE
// AND SCROLL_LEFT > 0
const buttonWidth = 44;
// Next Button
// VISIBLE IF:
// SCROLLABLE
// AND SCROLL_LEFT + CLIENT_WIDTH < SCROLL_WIDTH
const [isNextButtonVisible, setIsNextButtonVisible] = useState(ref.current ? scrollLeft + buttonWidth + ref.current.clientWidth < ref.current.scrollWidth : false);
const isPreviousButtonVisible = ref.current ? isScrollable && scrollLeft > 0 : false;
const previousButtonClasses = cx(`${prefix}--tab--overflow-nav-button`, `${prefix}--tab--overflow-nav-button--previous`, {
[`${prefix}--tab--overflow-nav-button--hidden`]: !isPreviousButtonVisible
});
const nextButtonClasses = cx(`${prefix}--tab--overflow-nav-button`, `${prefix}--tab--overflow-nav-button--next`, {
[`${prefix}--tab--overflow-nav-button--hidden`]: !isNextButtonVisible
});
const tabs = useRef([]);
const debouncedOnScroll = useCallback(() => {
const updateScroll = debounce(() => {
if (ref.current) {
setScrollLeft(ref.current.scrollLeft);
}
}, scrollDebounceWait);
updateScroll();
}, [scrollDebounceWait]);
function onKeyDown(event) {
if (matches(event, [ArrowRight, ArrowLeft, Home, End])) {
event.preventDefault();
const filteredTabs = tabs.current.filter(tab => tab !== null);
const activeTabs = filteredTabs.filter(tab => !tab.disabled);
const currentIndex = activeTabs.indexOf(tabs.current[activation === 'automatic' ? selectedIndex : activeIndex]);
const nextIndex = tabs.current.indexOf(activeTabs[getNextIndex(event, activeTabs.length, currentIndex)]);
if (activation === 'automatic') {
setSelectedIndex(nextIndex);
} else if (activation === 'manual') {
setActiveIndex(nextIndex);
}
tabs.current[nextIndex]?.focus();
}
}
function handleBlur({
relatedTarget: currentActiveNode
}) {
if (ref.current?.contains(currentActiveNode)) {
return;
}
// reset active index to selected tab index for manual activation
if (activation === 'manual') {
setActiveIndex(selectedIndex);
}
}
/**
* Scroll the tab into view if it is not already visible
* @param tab - The tab to scroll into view
* @returns {void}
*/
function scrollTabIntoView(tab) {
if (!isScrollable || !ref.current) {
return;
}
if (tab) {
// The width of the "scroll buttons"
const {
width: tabWidth
} = tab.getBoundingClientRect();
// The start and end position of the selected tab
const start = tab.offsetLeft;
const end = tab.offsetLeft + tabWidth;
// The start and end of the visible area for the tabs
const visibleStart = ref.current.scrollLeft + buttonWidth;
const visibleEnd = ref.current.scrollLeft + ref.current.clientWidth - buttonWidth;
// The beginning of the tab is clipped and not visible
if (start < visibleStart) {
setScrollLeft(start - buttonWidth);
}
// The end of the tab is clipped and not visible
if (end > visibleEnd) {
setScrollLeft(end + buttonWidth - ref.current.clientWidth);
}
}
}
useEffect(() => {
const tab = tabs.current[selectedIndex];
if (scrollIntoView && tab) {
tab.scrollIntoView({
block: 'nearest',
inline: 'nearest'
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452
}, []);
useEffect(() => {
//adding 1 in calculation for firefox support
setIsNextButtonVisible(ref.current ? scrollLeft + buttonWidth + ref.current.clientWidth + 1 < ref.current.scrollWidth : false);
if (dismissable) {
if (ref.current) {
setIsScrollable(ref.current.scrollWidth > ref.current.clientWidth);
}
}
}, [scrollLeft, children, dismissable, isScrollable]);
useEffect(() => {
if (tabs.current[selectedIndex]?.disabled) {
const activeTabs = tabs.current.filter(tab => {
return !tab.disabled;
});
if (activeTabs.length > 0) {
const tab = activeTabs[0];
setSelectedIndex(tabs.current.indexOf(tab));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452
}, []);
useIsomorphicEffect(() => {
if (ref.current) {
// adding 1 in calculation for firefox support
setIsScrollable(ref.current.scrollWidth > ref.current.clientWidth + 1);
}
function handler() {
if (ref.current) {
// adding 1 in calculation for firefox support
setIsScrollable(ref.current.scrollWidth > ref.current.clientWidth + 1);
}
}
const debouncedHandler = debounce(handler, 200);
window.addEventListener('resize', debouncedHandler);
return () => {
debouncedHandler.cancel();
window.removeEventListener('resize', debouncedHandler);
};
}, []);
// updates scroll location for all scroll behavior.
useIsomorphicEffect(() => {
if (scrollLeft !== null && ref.current) {
ref.current.scrollLeft = scrollLeft;
}
}, [scrollLeft]);
// scroll manual tabs when active index changes (focus outline movement)
useIsomorphicEffect(() => {
const tab = activation === 'manual' ? tabs.current[activeIndex] : tabs.current[selectedIndex];
scrollTabIntoView(tab);
}, [activation, activeIndex]);
// scroll tabs when selected index changes
useIsomorphicEffect(() => {
const tab = tabs.current[selectedIndex];
scrollTabIntoView(tab);
}, [selectedIndex, isScrollable, children]);
usePressable(previousButton, {
onPress({
longPress
}) {
if (!longPress && ref.current) {
setScrollLeft(Math.max(scrollLeft - ref.current.scrollWidth / tabs.current.length * 1.5, 0));
}
},
onLongPress() {
return createLongPressBehavior(ref, 'backward', setScrollLeft);
}
});
usePressable(nextButton, {
onPress({
longPress
}) {
if (!longPress && ref.current) {
setScrollLeft(Math.min(scrollLeft + ref.current.scrollWidth / tabs.current.length * 1.5, ref.current.scrollWidth - ref.current.clientWidth));
}
},
onLongPress() {
return createLongPressBehavior(ref, 'forward', setScrollLeft);
}
});
return /*#__PURE__*/React.createElement("div", {
className: className
}, /*#__PURE__*/React.createElement("button", _extends({
"aria-hidden": "true",
tabIndex: -1,
"aria-label": "Scroll left",
ref: previousButton,
className: previousButtonClasses,
type: "button"
}, leftOverflowButtonProps), _ChevronLeft || (_ChevronLeft = /*#__PURE__*/React.createElement(ChevronLeft, null))), /*#__PURE__*/React.createElement("div", _extends({}, rest, {
"aria-label": label,
ref: ref,
role: "tablist",
className: `${prefix}--tab--list`,
onScroll: debouncedOnScroll,
onKeyDown: onKeyDown,
onBlur: handleBlur
}), Children.map(children, (child, index) => {
return ! /*#__PURE__*/isValidElement(child) ? null : /*#__PURE__*/React.createElement(TabContext.Provider, {
value: {
index,
hasSecondaryLabel: hasSecondaryLabelTabs,
contained
}
}, /*#__PURE__*/cloneElement(child, {
ref: node => {
if (!node) return;
tabs.current[index] = node;
}
}));
})), /*#__PURE__*/React.createElement("button", _extends({
"aria-hidden": "true",
tabIndex: -1,
"aria-label": "Scroll right",
ref: nextButton,
className: nextButtonClasses,
type: "button"
}, rightOverflowButtonProps), _ChevronRight || (_ChevronRight = /*#__PURE__*/React.createElement(ChevronRight, null))));
}
TabList.propTypes = {
/**
* Specify whether the content tab should be activated automatically or
* manually
*/
activation: PropTypes.oneOf(['automatic', 'manual']),
/**
* Provide an accessible label to be read when a user interacts with this
* component
*/
'aria-label': PropTypes.string,
/**
* Provide child elements to be rendered inside `ContentTabs`.
* These elements should render a `ContentTab`
*/
children: PropTypes.node,
/**
* Specify an optional className to be added to the container node
*/
className: PropTypes.string,
/**
* Specify whether component is contained type
*/
contained: PropTypes.bool,
/**
* Used for tabs within a grid, this makes it so tabs span the full container width and have the same width. Only available on contained tabs with <9 children
*/
fullWidth: PropTypes.bool,
/**
* If using `IconTab`, specify the size of the icon being used.
*/
iconSize: PropTypes.oneOf(['default', 'lg']),
/**
* Provide the props that describe the left overflow button
*/
leftOverflowButtonProps: PropTypes.object,
/**
* Specify whether to use the light component variant
*/
light: deprecate(PropTypes.bool, 'The `light` prop for `TabList` has ' + 'been deprecated in favor of the new `Layer` component. It will be removed in the next major release.'),
/**
* Provide the props that describe the right overflow button
*/
rightOverflowButtonProps: PropTypes.object,
/**
* Optionally provide a delay (in milliseconds) passed to the lodash
* debounce of the onScroll handler. This will impact the responsiveness
* of scroll arrow buttons rendering when scrolling to the first or last tab.
*/
scrollDebounceWait: PropTypes.number,
/**
* Choose whether to automatically scroll
* to newly selected tabs on component rerender
*/
scrollIntoView: PropTypes.bool
};
/**
* TabListVertical
*/
// type TabElement = HTMLElement & { disabled?: boolean };
function TabListVertical({
activation = 'automatic',
'aria-label': label,
children,
className: customClassName,
scrollIntoView,
...rest
}) {
const {
activeIndex,
selectedIndex,
setSelectedIndex,
setActiveIndex
} = React.useContext(TabsContext);
const prefix = usePrefix();
const ref = useRef(null);
const [isOverflowingBottom, setIsOverflowingBottom] = useState(false);
const [isOverflowingTop, setIsOverflowingTop] = useState(false);
const isSm = useMatchMedia(smMediaQuery);
const className = cx(`${prefix}--tabs`, `${prefix}--tabs--vertical`, `${prefix}--tabs--contained`, customClassName);
const tabs = useRef([]);
function onKeyDown(event) {
if (matches(event, [ArrowDown, ArrowUp, Home, End])) {
event.preventDefault();
const filteredTabs = tabs.current.filter(tab => tab !== null);
const activeTabs = filteredTabs.filter(tab => !tab.disabled);
const currentIndex = activeTabs.indexOf(tabs.current[activation === 'automatic' ? selectedIndex : activeIndex]);
const nextIndex = tabs.current.indexOf(activeTabs[getNextIndexVertical(event, activeTabs.length, currentIndex)]);
if (activation === 'automatic') {
setSelectedIndex(nextIndex);
} else if (activation === 'manual') {
setActiveIndex(nextIndex);
}
tabs.current[nextIndex]?.focus();
}
}
function handleBlur({
relatedTarget: currentActiveNode
}) {
if (ref.current?.contains(currentActiveNode)) {
return;
}
// reset active index to selected tab index for manual activation
if (activation === 'manual') {
setActiveIndex(selectedIndex);
}
}
useEffect(() => {
if (tabs.current[selectedIndex]?.disabled) {
const activeTabs = tabs.current.filter(tab => {
return !tab.disabled;
});
if (activeTabs.length > 0) {
const tab = activeTabs[0];
setSelectedIndex(tabs.current.indexOf(tab));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452
}, []);
useEffect(() => {
function handler() {
const containerHeight = ref.current?.offsetHeight;
const containerTop = ref.current?.getBoundingClientRect().top;
const selectedPositionTop = tabs.current[selectedIndex]?.getBoundingClientRect().top;
const halfTabHeight = verticalTabHeight / 2;
if (containerTop && containerHeight) {
// scrolls so selected tab is in view
if (selectedPositionTop - halfTabHeight < containerTop || selectedPositionTop - containerTop + verticalTabHeight + halfTabHeight > containerHeight) {
ref.current?.scrollTo({
top: (selectedIndex - 1) * verticalTabHeight,
behavior: 'smooth'
});
}
}
}
window.addEventListener('resize', handler);
handler();
return () => {
window.removeEventListener('resize', handler);
};
}, [selectedIndex, scrollIntoView]);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const handler = () => {
const halfTabHeight = verticalTabHeight / 2;
setIsOverflowingBottom(element.scrollTop + element.clientHeight + halfTabHeight <= element.scrollHeight);
setIsOverflowingTop(element.scrollTop > halfTabHeight);
};
const resizeObserver = new ResizeObserver(() => handler());
resizeObserver.observe(element);
element.addEventListener('scroll', handler);
return () => {
resizeObserver.disconnect();
element.removeEventListener('scroll', handler);
};
});
if (isSm) {
return /*#__PURE__*/React.createElement(TabList, _extends({}, rest, {
"aria-label": label,
contained: true
}), children);
}
return /*#__PURE__*/React.createElement("div", {
className: className
}, isOverflowingTop && /*#__PURE__*/React.createElement("div", {
className: `${prefix}--tab--list-gradient_top`
}), /*#__PURE__*/React.createElement("div", _extends({}, rest, {
"aria-label": label,
ref: ref,
role: "tablist",
className: `${prefix}--tab--list`,
onKeyDown: onKeyDown,
onBlur: handleBlur
}), Children.map(children, (child, index) => {
return ! /*#__PURE__*/isValidElement(child) ? null : /*#__PURE__*/React.createElement(TabContext.Provider, {
value: {
index,
hasSecondaryLabel: false
}
}, /*#__PURE__*/cloneElement(child, {
ref: node => {
if (!node) return;
tabs.current[index] = node;
}
}));
})), isOverflowingBottom && /*#__PURE__*/React.createElement("div", {
className: `${prefix}--tab--list-gradient_bottom`
}));
}
TabListVertical.propTypes = {
/**
* Specify whether the content tab should be activated automatically or
* manually
*/
activation: PropTypes.oneOf(['automatic', 'manual']),
/**
* Provide an accessible label to be read when a user interacts with this
* component
*/
'aria-label': PropTypes.string,
/**
* Provide child elements to be rendered inside `ContentTabs`.
* These elements should render a `ContentTab`
*/
children: PropTypes.node,
/**
* Specify an optional className to be added to the container node
*/
className: PropTypes.string
};
/**
* Helper function to set up the behavior when a button is "long pressed".
* This function will take a ref to the tablist, a direction, and a setter
* for scrollLeft and will update the scroll position within a requestAnimationFrame.
*
* It returns a cleanup function to be run
* when the long press is deactivated
*/
function createLongPressBehavior(ref, direction, setScrollLeft) {
const node = ref.current;
if (!node) {
return () => {};
}
// We manually override the scroll behavior to be "auto".
// If it is set as smooth, this animation does not update correctly
const defaultScrollBehavior = node?.style['scroll-behavior'];
node.style['scroll-behavior'] = 'auto';
const scrollDelta = direction === 'forward' ? 5 : -5;
let frameId = null;
function tick() {
if (!node) {
return;
}
node.scrollLeft = node.scrollLeft + scrollDelta;
frameId = requestAnimationFrame(tick);
}
frameId = requestAnimationFrame(tick);
return () => {
// Restore the previous scroll behavior
node.style['scroll-behavior'] = defaultScrollBehavior;
// Make sure that our `scrollLeft` value is in sync with the existing
// `ref` after our requestAnimationFrame loop above
setScrollLeft(node.scrollLeft);
if (frameId) {
cancelAnimationFrame(frameId);
}
};
}
/**
* Tab
*/
// eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452
const Tab = /*#__PURE__*/forwardRef(({
as = 'button',
children,
className: customClassName,
disabled,
onClick,
onKeyDown,
secondaryLabel,
renderIcon: Icon,
...rest
}, forwardRef) => {
const prefix = usePrefix();
const {
selectedIndex,
setSelectedIndex,
baseId,
dismissable,
onTabCloseRequest
} = React.useContext(TabsContext);
const {
index,
hasSecondaryLabel,
contained
} = React.useContext(TabContext);
const {
badgeIndicator
} = React.useContext(IconTabContext) || {};
const dismissIconRef = useRef(null);
const tabRef = useRef(null);
const ref = useMergedRefs([forwardRef, tabRef]);
const [ignoreHover, setIgnoreHover] = useState(false);
const id = `${baseId}-tab-${index}`;
const panelId = `${baseId}-tabpanel-${index}`;
const [isEllipsisApplied, setIsEllipsisApplied] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452
const isEllipsisActive = element => {
setIsEllipsisApplied(element.offsetHeight < element.scrollHeight);
return element.offsetHeight < element.scrollHeight;
};
const className = cx(`${prefix}--tabs__nav-item`, `${prefix}--tabs__nav-link`, {
[`${prefix}--tabs__nav-item--selected`]: selectedIndex === index,
[`${prefix}--tabs__nav-item--disabled`]: disabled,
[`${prefix}--tabs__nav-item--hover-off`]: ignoreHover
}, customClassName);
const BaseComponent = as;
const onDismissIconMouseEnter = evt => {
if (contained && tabRef.current) {
evt.stopPropagation();
setIgnoreHover(true);
tabRef.current.classList.add(`${prefix}--tabs__nav-item--hover-off`);
}
};
const onDismissIconMouseLeave = () => {
if (contained && tabRef.current) {
tabRef.current.classList.remove(`${prefix}--tabs__nav-item--hover-off`);
setIgnoreHover(false);
}
};
useEvent(dismissIconRef, 'mouseover', onDismissIconMouseEnter);
useEvent(dismissIconRef, 'mouseleave', onDismissIconMouseLeave);
useIsomorphicEffect(() => {
function handler() {
const elementTabId = document.getElementById(`${id}`) || tabRef.current;
if (elementTabId?.closest(`.${prefix}--tabs--vertical`)) {
const newElement = elementTabId?.getElementsByClassName(`${prefix}--tabs__nav-item-label`)[0];
isEllipsisActive(newElement);
}
}
handler();
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}, [prefix, id]);
const handleClose = evt => {
evt.stopPropagation();
onTabCloseRequest?.(index);
// set focus after removing tab
if (tabRef.current && tabRef.current.parentElement) {
// determine number of tabs, excluding disabled
const tabCount = Array.from(tabRef.current.parentElement.childNodes).filter(node => {
const element = node;
return element.classList.contains(`${prefix}--tabs__nav-link`) && !element.classList.contains(`${prefix}--tabs__nav-item--disabled`);
}).length;
// if not removing last tab focus on next tab
if (tabRef.current && index + 1 !== tabCount) {
tabRef.current.focus();
}
// if removing last tab focus on previous tab
else {
const prevTabIndex = (tabCount - 2) * 2;
tabRef.current.parentElement.childNodes[prevTabIndex]?.focus();
}
}
};
const handleKeyDown = event => {
if (dismissable && match(event, Delete)) {
handleClose(event);
}
onKeyDown?.(event);
};
const DismissIcon = /*#__PURE__*/React.createElement("div", {
className: cx({
[`${prefix}--tabs__nav-item--close`]: dismissable,
[`${prefix}--tabs__nav-item--close--hidden`]: !dismissable
})
}, /*#__PURE__*/React.createElement("button", {
type: "button",
tabIndex: -1,
"aria-disabled": disabled,
"aria-hidden": selectedIndex === index && dismissable ? 'false' : 'true',
disabled: disabled,
className: cx({
[`${prefix}--tabs__nav-item--close-icon`]: dismissable,
[`${prefix}--visually-hidden`]: !dismissable,
[`${prefix}--tabs__nav-item--close-icon--selected`]: selectedIndex === index,
[`${prefix}--tabs__nav-item--close-icon--disabled`]: disabled
}),
onClick: handleClose,
title: `Remove ${typeof children === 'string' ? children : ''} tab`,
ref: dismissIconRef
}, /*#__PURE__*/React.createElement(Close, {
"aria-hidden": selectedIndex === index && dismissable ? 'false' : 'true',
"aria-label": `Press delete to remove ${typeof children === 'string' ? children : ''} tab`
})));
const hasIcon = Icon ?? dismissable;
// should only happen for vertical variation, so no dismissable icon is needed here
if (isEllipsisApplied) {
return /*#__PURE__*/React.createElement(Tooltip, {
label: children,
align: "top",
leaveDelayMs: 0,
autoAlign: true,
onMouseEnter: () => false,
closeOnActivation: true
}, /*#__PURE__*/React.createElement(BaseComponent, _extends({}, rest, {
"aria-controls": panelId,
"aria-disabled": disabled,
"aria-selected": selectedIndex === index,
ref: ref,
id: id,
role: "tab",
className: className,
disabled: disabled,
title: children,
onClick: evt => {
if (disabled) {
return;
}
setSelectedIndex(index);
onClick?.(evt);
},
onKeyDown: handleKeyDown,
tabIndex: selectedIndex === index ? '0' : '-1',
type: "button"
}), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--tabs__nav-item-label-wrapper`
}, /*#__PURE__*/React.createElement(Text, {
className: `${prefix}--tabs__nav-item-label`
}, children)), hasSecondaryLabel && secondaryLabel && /*#__PURE__*/React.createElement(Text, {
as: "div",
className: `${prefix}--tabs__nav-item-secondary-label`,
title: secondaryLabel
}, secondaryLabel)));
}
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(BaseComponent, _extends({}, rest, {
"aria-controls": panelId,
"aria-disabled": disabled,
"aria-selected": selectedIndex === index,
ref: ref,
id: id,
role: "tab",
className: className,
disabled: disabled,
onClick: evt => {
if (disabled) {
return;
}
setSelectedIndex(index);
onClick?.(evt);
},
onKeyDown: handleKeyDown,
tabIndex: selectedIndex === index ? '0' : '-1',
type: "button"
}), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--tabs__nav-item-label-wrapper`
}, dismissable && Icon && /*#__PURE__*/React.createElement("div", {
className: `${prefix}--tabs__nav-item--icon-left`
}, /*#__PURE__*/React.createElement(Icon, {
size: 16
})), /*#__PURE__*/React.createElement(Text, {
className: `${prefix}--tabs__nav-item-label`
}, children), !dismissable && Icon && /*#__PURE__*/React.createElement("div", {
className: cx(`${prefix}--tabs__nav-item--icon`, {
[`${prefix}--visually-hidden`]: !hasIcon
})
}, !dismissable && Icon && /*#__PURE__*/React.createElement(Icon, {
size: 16
}))), hasSecondaryLabel && secondaryLabel && /*#__PURE__*/React.createElement(Text, {
as: "div",
className: `${prefix}--tabs__nav-item-secondary-label`,
title: secondaryLabel
}, secondaryLabel), !disabled && badgeIndicator && (_BadgeIndicator || (_BadgeIndicator = /*#__PURE__*/React.createElement(BadgeIndicator, null)))), DismissIcon);
});
Tab.propTypes = {
/**
* Provide a custom element to render instead of the default button
*/
as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]),
/**
* Provide child elements to be rendered inside `Tab`.
*/
children: PropTypes.node,
/**
* Specify an optional className to be added to your Tab
*/
className: PropTypes.string,
/**
* Whether your Tab is disabled.
*/
disabled: PropTypes.bool,
/**
* Provide a handler that is invoked when a user clicks on the control
*/
onClick: PropTypes.func,
/**
* Provide a handler that is invoked on the key down event for the control
*/
onKeyDown: PropTypes.func,
/**
* An optional parameter to allow overriding the anchor rendering.
* Useful for using Tab along with react-router or other client
* side router libraries.
*/
renderButton: PropTypes.func,
/**
* A component used to render an icon.
*/
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
/**
* An optional label to render under the primary tab label.
* Only useful for contained tabs.
*/
secondaryLabel: PropTypes.string
};
/**
* IconTab
*/
const IconTabContext = /*#__PURE__*/createContext(false);
// eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452
const IconTab = /*#__PURE__*/React.forwardRef(({
badgeIndicator,
children,
className: customClassName,
defaultOpen = false,
enterDelayMs,
leaveDelayMs,
label,
...rest
}, ref) => {
const prefix = usePrefix();
const value = useMemo(() => ({
badgeIndicator
}), [badgeIndicator]);
const hasSize20 = /*#__PURE__*/isValidElement(children) &&
// TODO: The interface allows `size` to be a string. Should this case be
// handled here, or should the prop type be restricted to `number`
// instead?
children.props.size === 20;
const classNames = cx(`${prefix}--tabs__nav-item--icon-only`, customClassName, {
[`${prefix}--tabs__nav-item--icon-only__20`]: hasSize20
});
return /*#__PURE__*/React.createElement(IconTabContext.Provider, {
value: value
}, /*#__PURE__*/React.createElement(Tooltip, {
align: "bottom",
defaultOpen: defaultOpen,
className: `${prefix}--icon-tooltip`,
enterDelayMs: enterDelayMs,
label: label,
leaveDelayMs: leaveDelayMs
}, /*#__PURE__*/React.createElement(Tab, _extends({
className: classNames,
ref: ref
}, rest), children)));
});
IconTab.propTypes = {
/**
* **Experimental**: Display an empty dot badge on the Tab.
*/
badgeIndicator: PropTypes.bool,
/**
* Provide an icon to be rendered inside `IconTab` as the visual label for Tab.
*/
children: PropTypes.node,
/**
* Specify an optional className to be added to your Tab
*/
className: PropTypes.string,
/**
* Specify whether the tooltip for the icon should be open when it first renders
*/
defaultOpen: PropTypes.bool,
/**
* Specify the duration in milliseconds to delay before displaying the tooltip for the icon.
*/
enterDelayMs: PropTypes.number,
/**
* Provide the label to be rendered inside the Tooltip. The label will use
* `aria-labelledby` and will fully describe the child node that is provided.
* This means that if you have text in the child node it will not be
* announced to the screen reader. If using the badgeIndicator then provide a
* label with describing that there is a new notification.
*/
label: PropTypes.node.isRequired,
/**
* Specify the duration in milliseconds to delay before hiding the tooltip
*/
leaveDelayMs: PropTypes.number
};
/**
* TabPanel
*/
// eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452
const TabPanel = /*#__PURE__*/React.forwardRef(({
children,
className: customClassName,
...rest
}, forwardRef) => {
const prefix = usePrefix();
const {
selectedIndex,
baseId
} = React.useContext(TabsContext);
const index = React.useContext(TabPanelContext);
const id = `${baseId}-tabpanel-${index}`;
const tabId = `${baseId}-tab-${index}`;
const className = cx(`${prefix}--tab-content`, customClassName);
return /*#__PURE__*/React.createElement("div", _extends({}, rest, {
"aria-labelledby": tabId,
id: id,
className: className,
ref: forwardRef,
role: "tabpanel",
hidden: selectedIndex !== index
}), children);
});
TabPanel.propTypes = {
/**
* Provide child elements to be rendered inside `TabPanel`.
*/
children: PropTypes.node,
/**
* Specify an optional className to be added to TabPanel.
*/
className: PropTypes.string
};
/**
* TabPanels
*/
function TabPanels({
children
}) {
const prefix = usePrefix();
const refs = useRef([]);
const hiddenStates = useRef([]);
useIsomorphicEffect(() => {
const tabContainer = refs.current[0]?.previousElementSibling;
const isVertical = tabContainer?.classList.contains(`${prefix}--tabs--vertical`);
const parentHasHeight = tabContainer?.parentElement?.style.height;
// Should only apply same height to vertical Tab Panels without a given height
if (isVertical && !parentHasHeight) {
hiddenStates.current = refs.current.map(ref => ref?.hidden || false);
// un-hide hidden Tab Panels to get heights
refs.current.forEach(ref => {
if (ref) {
ref.hidden = false;
}
});
// set max height to TabList
const heights = refs.current.map(ref => ref?.offsetHeight || 0);
const max = Math.max(...heights);
tabContainer.style.height = max + 'px';
// re-hide hidden Tab Panels
refs.current.forEach((ref, index) => {
if (ref) {
ref.hidden = hiddenStates.current[index];
}
});
}
});
return /*#__PURE__*/React.createElement(React.Fragment, null, Children.map(children, (child, index) => {
return ! /*#__PURE__*/isValidElement(child) ? null : /*#__PURE__*/React.createElement(TabPanelContext.Provider, {
value: index
}, /*#__PURE__*/cloneElement(child, {
ref: element => {
refs.current[index] = element;
}
}));
}));
}
TabPanels.propTypes = {
/**
* Provide child elements to be rendered inside `TabPanels`.
*/
children: PropTypes.node
};
export { IconTab, Tab, TabList, TabListVertical, TabPanel, TabPanels, Tabs, TabsVertical };