import React, { MouseEvent, ReactElement, ReactNode, RefObject, createRef } from 'react'; import PropTypes from 'prop-types'; import cls from 'classnames'; import { cssClasses, strings } from '@douyinfe/semi-foundation/tabs/constants'; import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr'; import OverflowList from '../overflowList'; import Dropdown, { DropdownProps } from '../dropdown'; import Button from '../button'; import { TabBarProps, PlainTab } from './interface'; import { isEmpty, pick } from 'lodash'; import { IconChevronRight, IconChevronLeft, IconChevronDown } from '@douyinfe/semi-icons'; import { getUuidv4 } from '@douyinfe/semi-foundation/utils/uuid'; import TabItem from './TabItem'; import { Locale } from "../locale/interface"; import LocaleConsumer from "../locale/localeConsumer"; import ResizeObserver from '../resizeObserver'; export interface TabBarState { endInd: number; rePosKey: number; startInd: number; uuid: string; currentVisibleItems: string[]; shouldCollapse: boolean; } export interface OverflowItem extends PlainTab { key: string; active: boolean } class TabBar extends React.Component { static propTypes = { activeKey: PropTypes.string, className: PropTypes.string, collapsible: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['auto'])]), list: PropTypes.array, onTabClick: PropTypes.func, size: PropTypes.oneOf(strings.SIZE), style: PropTypes.object, tabBarExtraContent: PropTypes.node, tabPosition: PropTypes.oneOf(strings.POSITION_MAP), type: PropTypes.oneOf(strings.TYPE_MAP), closable: PropTypes.bool, deleteTabItem: PropTypes.func, more: PropTypes.oneOfType([PropTypes.number, PropTypes.object]), }; private isFirstShowInViewport: boolean; private tabBarRef: RefObject; constructor(props: TabBarProps) { super(props); this.state = { endInd: props.list.length, rePosKey: 0, startInd: 0, uuid: '', currentVisibleItems: [], shouldCollapse: false, }; this.isFirstShowInViewport = true; this.tabBarRef = createRef(); } componentDidMount() { this.setState({ uuid: getUuidv4(), }); // Auto detect overflow when collapsible is 'auto' // Note: ResizeObserver will trigger handleResize automatically after mount // But we also call checkOverflow here as a fallback in case ResizeObserver doesn't fire if (this.props.collapsible === 'auto') { // Use requestAnimationFrame to ensure DOM is ready requestAnimationFrame(() => this.checkOverflow()); } } componentDidUpdate(prevProps) { if (prevProps.activeKey !== this.props.activeKey) { const effectiveCollapsible = this.getEffectiveCollapsible(); if (effectiveCollapsible) { this.scrollActiveTabItemIntoView(); } } // Re-check overflow when list changes in auto mode if (this.props.collapsible === 'auto' && prevProps.list !== this.props.list) { this.checkOverflow(); } } getEffectiveCollapsible = (): boolean => { const { collapsible } = this.props; if (collapsible === 'auto') { return this.state.shouldCollapse; } return collapsible || false; }; checkOverflow = () => { // In auto mode: // - when not collapsed yet, detect overflow by checking if tabs have wrapped into multiple rows // - when collapsed, rely on OverflowList visible state callback to decide whether to exit collapse if (this.props.collapsible !== 'auto') { return; } if (this.state.shouldCollapse) { return; } const tabBarEl = this.tabBarRef.current; if (!tabBarEl) { return; } const hasOverflow = this.isTabsWrapped(tabBarEl) || tabBarEl.scrollWidth > tabBarEl.clientWidth + 1; if (hasOverflow !== this.state.shouldCollapse) { this.setState({ shouldCollapse: hasOverflow }); } }; isTabsWrapped = (tabBarEl: HTMLDivElement): boolean => { // Only meaningful in horizontal mode if (this.props.tabPosition === 'left') { return false; } const tabNodes = Array.from(tabBarEl.querySelectorAll(`.${cssClasses.TABS_TAB}`)) as HTMLElement[]; if (tabNodes.length <= 1) { return false; } const firstTop = tabNodes[0]?.offsetTop; if (typeof firstTop !== 'number') { return false; } return tabNodes.some(node => node.offsetTop !== firstTop); }; handleResize = () => { if (this.props.collapsible === 'auto') { this.checkOverflow(); } }; renderIcon(icon: ReactNode): ReactNode { return ( {icon} ); } renderExtra(): ReactNode { const { tabBarExtraContent, type, size } = this.props; const tabBarExtraContentDefaultStyle = { float: 'right' }; const tabBarExtraContentStyle = tabBarExtraContent && (tabBarExtraContent as ReactElement).props ? (tabBarExtraContent as ReactElement).props.style : {}; const extraCls = cls(cssClasses.TABS_BAR_EXTRA, { [`${cssClasses.TABS_BAR}-${type}-extra`]: type, [`${cssClasses.TABS_BAR}-${type}-extra-${size}`]: size, }); if (tabBarExtraContent) { const tabBarStyle = { ...tabBarExtraContentDefaultStyle, ...tabBarExtraContentStyle }; return (
{tabBarExtraContent}
); } return null; } handleItemClick = (itemKey: string, e: MouseEvent): void => { this.props.onTabClick(itemKey, e); }; handleKeyDown = (event: React.KeyboardEvent, itemKey: string, closable: boolean) => { this.props.handleKeyDown(event, itemKey, closable); } renderTabItem = (panel: PlainTab): ReactNode => { const { size, type, deleteTabItem, handleKeyDown, tabPosition } = this.props; const isSelected = this._isActive(panel.itemKey); return ( ); }; scrollTabItemIntoViewByKey = (key: string, logicalPosition: ScrollLogicalPosition = 'nearest', behavior?: ScrollBehavior) => { const tabItem = document.querySelector(`[data-uuid="${this.state.uuid}"] .${cssClasses.TABS_TAB}[data-scrollkey="${key}"]`); tabItem?.scrollIntoView({ behavior: behavior || 'smooth', block: logicalPosition, inline: logicalPosition }); } scrollActiveTabItemIntoView = (logicalPosition?: ScrollLogicalPosition, behavior?: ScrollBehavior) => { const key = this._getBarItemKeyByItemKey(this.props.activeKey); this.scrollTabItemIntoViewByKey(key, logicalPosition, behavior); } renderTabComponents = (list: Array): Array => list.map(panel => this.renderTabItem(panel)); handleArrowClick = (items: Array, pos: 'start' | 'end'): void => { const lastItem = pos === 'start' ? items.pop() : items.shift(); if (!lastItem) { return; } const key = this._getBarItemKeyByItemKey(lastItem.itemKey); this.scrollTabItemIntoViewByKey(key); }; renderCollapse = (items: Array, icon: ReactNode, pos: 'start' | 'end'): ReactNode => { const arrowCls = cls({ [`${cssClasses.TABS_BAR}-arrow-${pos}`]: pos, [`${cssClasses.TABS_BAR}-arrow`]: true, }); if (isEmpty(items)) { return (
); } const { dropdownClassName, dropdownStyle, showRestInDropdown, dropdownProps } = this.props; const { rePosKey } = this.state; const disabled = !items.length; const menu = ( {items.map(panel => { const { icon: i, tab, itemKey } = panel; const panelIcon = i ? this.renderIcon(panel.icon) : null; return ( this.handleItemClick(itemKey, e)} active={this._isActive(itemKey)} > {panelIcon} {tab} ); })} ); const button = (
this.handleArrowClick(items, pos)}>
); const dropdownCls = cls(dropdownClassName, { [`${cssClasses.TABS_BAR}-dropdown`]: true, }); const customDropdownProps = dropdownProps?.[pos] ?? {}; return ( <> {showRestInDropdown ? ( {button} ) : (button)} ); }; renderOverflow = (items: any[]): Array => items.map((item, index) => { const pos = index === 0 ? 'start' : 'end'; const icon = index === 0 ? : ; const overflowNode = this.renderCollapse(item, icon, pos); if (this.props.renderArrow) { return this.props.renderArrow(item, pos, () => this.handleArrowClick(item, pos), overflowNode); } return overflowNode; }); renderCollapsedTab = (): ReactNode => { const { list } = this.props; const renderedList = list.map(item => { const { itemKey } = item; return { key: this._getBarItemKeyByItemKey(itemKey), active: this._isActive(itemKey), ...item }; }); return ( { const visibleMapWithItemKey: Map = new Map(); visibleMap.forEach((v, k ) => { visibleMapWithItemKey.set(this._getItemKeyByBarItemKey(k), v); }); // only when the tabs component appears in the viewport for the first time triggered scrollActiveTabItemIntoView // refer to issue 2917 https://github.com/DouyinFE/semi-design/issues/2917 if (this.isFirstShowInViewport) { const isShowInViewport = Array.from(visibleMapWithItemKey.values()).some(item => item); if (isShowInViewport) { this.scrollActiveTabItemIntoView('nearest', 'auto'); this.isFirstShowInViewport = false; } } // Auto mode: if everything is visible, exit collapse; if something is hidden, keep collapse. // Avoid toggling when component is not in viewport (all false). if (this.props.collapsible === 'auto') { const isShowInViewport = Array.from(visibleMapWithItemKey.values()).some(v => v); if (isShowInViewport) { const hasOverflow = Array.from(visibleMapWithItemKey.values()).some(v => !v); if (hasOverflow !== this.state.shouldCollapse) { this.setState({ shouldCollapse: hasOverflow }); } } } this.props.onVisibleTabsChange?.(visibleMapWithItemKey); }} /> ); }; renderWithMoreTrigger = (): ReactNode => { const { list, more } = this.props; let tabElements: ReactNode[] = []; let moreTrigger: ReactNode =
{(locale: Locale['Tabs'], localeCode: Locale['code']) => (
{locale.more}
)}
; let keepCount: number; if (typeof more === "number") { keepCount = list.length - Math.min(more, list.length); tabElements = list.slice(0, keepCount).map(panel => this.renderTabItem(panel)); } else if (typeof more === 'object') { keepCount = list.length - Math.min(more.count, list.length); tabElements = list.slice(0, keepCount).map(panel => this.renderTabItem(panel)); if (more.render) { moreTrigger = more.render(); } } else if (more !== undefined) { throw new Error("[Semi Tabs]: invalid tab props format: more"); } return <> {tabElements} {this.renderMoreDropdown(list.slice(keepCount), more?.['dropdownProps'], moreTrigger)} ; } renderMoreDropdown = (panels: PlainTab[], dropDownProps: DropdownProps, trigger: ReactNode): ReactNode => { return ({ node: 'item', name: panel.tab as string, icon: panel.icon, onClick: (e) => this.props.onTabClick(panel.itemKey, e), active: this.props.activeKey === panel.itemKey }))} {...dropDownProps} > {trigger} ; } render(): ReactNode { const { type, style, className, list, tabPosition, more, collapsible, ...restProps } = this.props; const effectiveCollapsible = this.getEffectiveCollapsible(); const classNames = cls(className, { [cssClasses.TABS_BAR]: true, [cssClasses.TABS_BAR_LINE]: type === 'line', [cssClasses.TABS_BAR_CARD]: type === 'card', [cssClasses.TABS_BAR_BUTTON]: type === 'button', [cssClasses.TABS_BAR_SLASH]: type === 'slash', [`${cssClasses.TABS_BAR}-${tabPosition}`]: tabPosition, [`${cssClasses.TABS_BAR}-collapse`]: effectiveCollapsible, }); const extra = this.renderExtra(); const contents = effectiveCollapsible ? this.renderCollapsedTab() : (more ? this.renderWithMoreTrigger() : this.renderTabComponents(list)); const tabBarContent = (
{contents} {extra}
); // Wrap with ResizeObserver for auto mode if (collapsible === 'auto') { return ( {tabBarContent} ); } return tabBarContent; } private _isActive = (key: string): boolean => key === this.props.activeKey; private _getBarItemKeyByItemKey = (key: string): string => `${key}-bar`; private _getItemKeyByBarItemKey = (key: string): string => key.replace(/-bar$/, ""); } export default TabBar;