/** * @file Tabs * @description 选项卡 * @author fex */ import React from 'react'; import {ClassName, localeable, LocaleProps, Schema} from 'amis-core'; import Transition, {ENTERED, ENTERING} from 'react-transition-group/Transition'; import {themeable, ThemeProps, noop} from 'amis-core'; import {uncontrollable} from 'amis-core'; import {isObjectShallowModified} from 'amis-core'; import {autobind, guid} from 'amis-core'; import {Icon} from './icons'; import debounce from 'lodash/debounce'; import {findDOMNode} from 'react-dom'; import TooltipWrapper from './TooltipWrapper'; import {resizeSensor} from 'amis-core'; import PopOverContainer from './PopOverContainer'; import Sortable from 'sortablejs'; const transitionStyles: { [propName: string]: string; } = { [ENTERING]: 'in', [ENTERED]: 'in' }; export type TabsMode = | '' | 'line' | 'card' | 'radio' | 'vertical' | 'chrome' | 'simple' | 'strong' | 'tiled' | 'sidebar'; export interface TabProps extends ThemeProps { title?: string | React.ReactNode; // 标题 icon?: string; iconPosition?: 'left' | 'right'; disabled?: boolean | string; eventKey: string | number; prevKey?: string | number; nextKey?: string | number; tip?: string; tab?: Schema; className?: string; activeKey?: string | number; reload?: boolean; mountOnEnter?: boolean; unmountOnExit?: boolean; toolbar?: React.ReactNode; children?: React.ReactNode | Array; swipeable?: boolean; onSelect?: (eventKey: string | number) => void; } class TabComponent extends React.PureComponent { contentDom: any; touch: any = {}; touchStartTime: number; contentRef = (ref: any) => (this.contentDom = ref); @autobind onTouchStart(event: React.TouchEvent) { this.touch.startX = event.touches[0].clientX; this.touch.startY = event.touches[0].clientY; this.touchStartTime = Date.now(); } @autobind onTouchMove(event: React.TouchEvent) { const touch = event.touches[0]; const newState = {...this.touch}; newState.deltaX = touch.clientX < 0 ? 0 : touch.clientX - newState.startX; newState.deltaY = touch.clientY - newState.startY; newState.offsetX = Math.abs(newState.deltaX); newState.offsetY = Math.abs(newState.deltaY); this.touch = newState; } @autobind onTouchEnd() { const duration = Date.now() - this.touchStartTime; const speed = this.touch.deltaX / duration; const shouldSwipe = Math.abs(speed) > 0.25; const {prevKey, nextKey, onSelect} = this.props; if (shouldSwipe) { if (this.touch.deltaX > 0) { prevKey !== undefined && onSelect?.(prevKey); } else { nextKey && onSelect?.(nextKey); } } } render() { const { classnames: cx, mountOnEnter, reload, unmountOnExit, eventKey, activeKey, children, className, swipeable, mobileUI } = this.props; return ( {(status: string) => { if (status === ENTERING) { this.contentDom.offsetWidth; } return (
{children}
); }}
); } } export const Tab = themeable(TabComponent); export interface TabsProps extends ThemeProps, LocaleProps { mode: TabsMode; tabsMode?: TabsMode; additionBtns?: React.ReactNode; onSelect?: (key: string | number) => void; activeKey?: string | number; contentClassName: string; linksClassName?: ClassName; className?: string; tabs?: Array; tabRender?: (tab: TabProps, props?: TabsProps) => JSX.Element; toolbar?: React.ReactNode; addable?: boolean; // 是否显示增加按钮 onAdd?: () => void; closable?: boolean; onClose?: (index: number, key: string | number) => void; draggable?: boolean; onDragChange?: (e: any) => void; showTip?: boolean; showTipClassName?: string; scrollable?: boolean; // 属性废弃,为了兼容暂且保留 editable?: boolean; onEdit?: (index: number, text: string) => void; sidePosition?: 'left' | 'right'; addBtnText?: string; collapseOnExceed?: number; collapseBtnLabel?: string; popOverContainer?: any; children?: React.ReactNode | Array; } export interface IDragInfo { nodeId: string; } export class Tabs extends React.Component { static defaultProps: Pick< TabsProps, | 'mode' | 'contentClassName' | 'showTip' | 'showTipClassName' | 'sidePosition' | 'addBtnText' | 'collapseBtnLabel' > = { mode: '', contentClassName: '', showTip: false, showTipClassName: '', sidePosition: 'left', addBtnText: '新增', // 由于组件用的地方比较多,这里暂时不好改翻译 collapseBtnLabel: 'more' }; static Tab = Tab; navMain = React.createRef(); // HTMLDivElement scroll: boolean = false; sortable?: Sortable; dragTip?: HTMLElement; id: string = guid(); draging: boolean = false; toDispose: Array<() => void> = []; resizeDom = React.createRef(); checkArrowStatus = debounce( () => { const {scrollLeft, scrollWidth, clientWidth} = this.navMain.current || { scrollLeft: 0, scrollWidth: 0, clientWidth: 0 }; const {arrowRightDisabled, arrowLeftDisabled} = this.state; if (scrollLeft === 0 && !arrowLeftDisabled) { this.setState({ arrowRightDisabled: false, arrowLeftDisabled: true }); } else if ( scrollWidth === scrollLeft + clientWidth && !arrowRightDisabled ) { this.setState({ arrowRightDisabled: true, arrowLeftDisabled: false }); } else if (scrollLeft !== 0 && arrowLeftDisabled) { this.setState({ arrowLeftDisabled: false }); } else if ( scrollWidth !== scrollLeft + clientWidth && arrowRightDisabled ) { this.setState({ arrowRightDisabled: false }); } }, 100, { trailing: true, leading: false } ); constructor(props: TabsProps) { super(props); this.state = { isOverflow: false, arrowLeftDisabled: false, arrowRightDisabled: false, dragIndicator: null, editingIndex: null, editInputText: null, editOriginText: null }; } componentDidMount() { this.computedWidth(); if (this.navMain) { this.navMain.current?.addEventListener('wheel', this.handleWheel, { passive: false }); this.checkArrowStatus(); } this.resizeDom?.current && this.toDispose.push( resizeSensor(this.resizeDom.current as HTMLElement, () => this.computedWidth() ) ); } componentDidUpdate(preProps: any) { // 只有 key 变化或者 tab 改变,才会重新计算,避免多次计算导致 顶部标签 滚动问题 const isTabsModified = isObjectShallowModified( { activeKey: this.props.activeKey, children: Array.isArray(this.props.children) ? this.props.children!.map(item => ({ eventKey: (item as JSX.Element)?.props?.eventKey, // 这里 title 可能是 React.ReactNode,只对比 string title: typeof (item as JSX.Element)?.props?.title === 'string' ? (item as JSX.Element).props.title : '' })) : [] }, { activeKey: preProps.activeKey, children: Array.isArray(preProps.children) ? preProps.children!.map((item: any) => ({ eventKey: item?.props?.eventKey, title: typeof item?.props?.title === 'string' ? item.props.title : '' })) : [] } ); // 判断是否是由滚动触发的数据更新,如果是则不需要再次判断容器与内容的关系 if (!this.scroll && !this.draging && isTabsModified) { this.computedWidth(); } // 移动端取消箭头切换,改为滚动切换激活项居中 const {classPrefix: ns, activeKey, mobileUI} = this.props; if (mobileUI && preProps.activeKey !== activeKey) { const {classPrefix: ns} = this.props; const dom = findDOMNode(this) as HTMLElement; const activeTab = dom.querySelector( `.${ns}Tabs-link.is-active` ) as HTMLElement; const parentWidth = (activeTab.parentNode?.parentNode as any).offsetWidth; const offsetLeft = activeTab.offsetLeft; const offsetWidth = activeTab.offsetWidth; if (activeTab.parentNode) { (activeTab.parentNode as any).scrollLeft = offsetLeft > parentWidth ? (offsetLeft / parentWidth) * parentWidth - parentWidth / 2 + offsetWidth / 2 : offsetLeft - parentWidth / 2 + offsetWidth / 2; } } this.scroll = false; } componentWillUnmount() { this.checkArrowStatus.cancel(); this.toDispose.forEach(fn => fn()); this.toDispose = []; } /** * 处理内容与容器之间的位置关系 */ computedWidth() { const {mode: dMode, tabsMode} = this.props; const mode = tabsMode || dMode; if (['vertical', 'sidebar'].includes(mode)) { return; } const navMainRef = this.navMain.current; const clientWidth: number = navMainRef?.clientWidth || 0; const scrollWidth: number = navMainRef?.scrollWidth || 0; const isOverflow = scrollWidth > clientWidth; // 内容超出容器长度标记溢出 if (isOverflow !== this.state.isOverflow) { this.setState({isOverflow}); } // 正在拖动的不自动定位 if (isOverflow && !this.draging) { this.showSelected(); } } /** * 保证选中的tab始终显示在可视区域 */ showSelected(key?: string | number) { const {mode: dMode, tabsMode} = this.props; const {isOverflow} = this.state; const mode = tabsMode || dMode; if (['vertical', 'sidebar'].includes(mode) || !isOverflow) { return; } const {activeKey, children} = this.props; const currentKey = key !== undefined ? key : activeKey; const currentIndex = (children as any[])?.findIndex( (item: any) => item.props.eventKey === currentKey ); const li = this.navMain.current?.children || []; const currentLi = li[currentIndex] as HTMLElement; const liOffsetLeft = currentLi?.offsetLeft; const liClientWidth = currentLi?.clientWidth; const scrollLeft = this.navMain.current?.scrollLeft || 0; const clientWidth = this.navMain.current?.clientWidth || 0; // 左边被遮住了 if (scrollLeft > liOffsetLeft) { this.navMain.current?.scrollTo({ left: liOffsetLeft, behavior: 'smooth' }); } // 右边被遮住了 if (liOffsetLeft + liClientWidth > scrollLeft + clientWidth) { this.navMain.current?.scrollTo({ left: liOffsetLeft + liClientWidth - clientWidth, behavior: 'smooth' }); } } handleSelect(key: string | number) { const {onSelect} = this.props; this.showSelected(key); setTimeout(() => { this.checkArrowStatus(); }, 500); onSelect && onSelect(key); } @autobind handleStartEdit(index: number, title: string) { this.setState({ editingIndex: index, editInputText: title, editOriginText: title }); } @autobind handleEditInputChange(e: React.ChangeEvent) { this.setState({ editInputText: e.currentTarget.value }); } @autobind handleEdit() { let {editingIndex, editInputText, editOriginText} = this.state; const {onEdit} = this.props; this.setState({ editingIndex: null, editInputText: null, editOriginText: null }); onEdit && (editInputText = String(editInputText).trim()) && editInputText !== editOriginText && onEdit(editingIndex, editInputText); } @autobind dragTipRef(ref: any) { if (!this.dragTip && ref) { this.initDragging(); } else if (this.dragTip && !ref) { this.destroyDragging(); } this.dragTip = ref; } @autobind destroyDragging() { this.sortable && this.sortable.destroy(); } @autobind initDragging() { const {classPrefix: ns, onDragChange} = this.props; const dom = findDOMNode(this) as HTMLElement; this.sortable = new Sortable( dom.querySelector(`.${ns}Tabs-links`) as HTMLElement, { group: this.id, animation: 250, handle: `.${ns}Tabs-link`, ghostClass: `${ns}Tabs-link--dragging`, onStart: () => { this.draging = true; }, onEnd: (e: any) => { // 没有移动 if (e.newIndex === e.oldIndex) { return; } // 再交换回来 const parent = e.to as HTMLElement; if (e.oldIndex < parent.childNodes.length - 1) { parent.insertBefore( e.item, parent.childNodes[ e.oldIndex > e.newIndex ? e.oldIndex + 1 : e.oldIndex ] ); } else { parent.appendChild(e.item); } setTimeout(() => { this.draging = false; }); onDragChange && onDragChange(e); } } ); } handleArrow(type: 'left' | 'right') { const {scrollLeft, scrollWidth, clientWidth} = this.navMain.current || { scrollLeft: 0, scrollWidth: 0, clientWidth: 0 }; if (type === 'left' && scrollLeft > 0) { const newScrollLeft = scrollLeft - clientWidth; this.navMain.current?.scrollTo({ left: newScrollLeft > 0 ? newScrollLeft : 0, behavior: 'smooth' }); this.setState({ arrowRightDisabled: false, arrowLeftDisabled: newScrollLeft <= 0 }); } else if (type === 'right' && scrollWidth > scrollLeft + clientWidth) { const newScrollLeft = scrollLeft + clientWidth; this.navMain.current?.scrollTo({ left: newScrollLeft > scrollWidth ? scrollWidth : newScrollLeft, behavior: 'smooth' }); this.setState({ arrowRightDisabled: newScrollLeft > scrollWidth - clientWidth, arrowLeftDisabled: false }); } this.scroll = true; } /** * 监听导航上的滚动事件 */ @autobind handleWheel(e: WheelEvent) { const {deltaY, deltaX} = e; const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); // 当鼠标上下滚动时转换为左右滚动 if (absY > absX) { this.navMain.current?.scrollTo({ left: this.navMain.current?.scrollLeft + deltaY }); e.preventDefault(); } this.checkArrowStatus(); this.scroll = true; } // 处理 hash 作为 key 时重复的问题 generateTabKey(hash: any, eventKey: any, index: number) { return (hash === eventKey ? 'hash-' : '') + (eventKey ?? index); } renderNav(child: any, index: number, showClose: boolean) { if (!child) { return; } const { classnames: cx, activeKey: activeKeyProp, mode, closable, draggable, showTip, showTipClassName, editable } = this.props; const { eventKey, disabled, icon, iconPosition, title, toolbar, tabClassName, closable: tabClosable, tip, hash } = child.props; const {editingIndex, editInputText} = this.state; const activeKey = activeKeyProp === undefined && index === 0 ? eventKey : activeKeyProp; const iconElement = ; const link = ( {editable && editingIndex === index ? ( ) => e.currentTarget.select() } onChange={this.handleEditInputChange} onBlur={this.handleEdit} onKeyPress={(e: React.KeyboardEvent) => e && e.key === 'Enter' && this.handleEdit() } /> ) : ( <> {icon ? ( iconPosition === 'right' ? ( <> {title} {iconElement} ) : ( <> {iconElement} {title} ) ) : ( title )} {React.isValidElement(toolbar) ? toolbar : null} )} ); return (
  • (disabled ? '' : this.handleSelect(eventKey))} onDoubleClick={() => { editable && typeof title === 'string' && this.handleStartEdit(index, title); }} > {showTip ? ( {link} ) : ( link )} {showClose && (tabClosable ?? closable) && ( { e.stopPropagation(); this.props.onClose && this.props.onClose(index, eventKey ?? index); }} > )} {mode === 'chrome' ? (
    ) : null}
  • ); } renderTab(child: any, index: number) { if (!child) { return; } const {hash, eventKey} = child?.props || {}; const {activeKey: activeKeyProp, classnames} = this.props; const activeKey = activeKeyProp === undefined && index === 0 ? eventKey : activeKeyProp; return React.cloneElement(child, { ...child.props, key: this.generateTabKey(hash, eventKey, index), classnames: classnames, activeKey: activeKey }); } renderArrow(type: 'left' | 'right') { const {mode: dMode, tabsMode} = this.props; const mode = tabsMode || dMode; if (['vertical', 'sidebar'].includes(mode)) { return; } const {classnames: cx} = this.props; const {isOverflow, arrowLeftDisabled, arrowRightDisabled} = this.state; const disabled = type === 'left' ? arrowLeftDisabled : arrowRightDisabled; return isOverflow ? (
    this.handleArrow(type)} className={cx( 'Tabs-linksContainer-arrow', 'Tabs-linksContainer-arrow--' + type, disabled && 'Tabs-linksContainer-arrow--disabled' )} >
    ) : null; } handleAddBtn() { const {onAdd} = this.props; onAdd && onAdd(); } renderNavs(showClose = false) { const { children, collapseOnExceed, translate: __, classnames: cx, popOverContainer, collapseBtnLabel } = this.props; if (!Array.isArray(children)) { return null; } let doms: Array = (children as Array).map((tab, index) => this.renderNav(tab, index, showClose) ); if ( typeof collapseOnExceed == 'number' && collapseOnExceed && doms.length > collapseOnExceed ) { const rest = doms.splice( collapseOnExceed - 1, doms.length + 1 - collapseOnExceed ); doms.push( findDOMNode(this))} popOverRender={({onClose}) => (
      {rest}
    )} > {({onClick, ref, isOpened}) => (
  • ~item.props.className.indexOf('is-active')) ? 'is-active' : '' )} > {__(collapseBtnLabel || 'more')}
  • )}
    ); } return doms; } render() { const { classnames: cx, contentClassName, className, style, mode: dMode, tabsMode, children, additionBtns, toolbar, linksClassName, addable, draggable, sidePosition, addBtnText, mobileUI } = this.props; const {isOverflow} = this.state; if (!Array.isArray(children)) { return null; } const mode = tabsMode || dMode; const toolButtons = ( <> {addable && (
    this.handleAddBtn()} > {addBtnText}
    )} {toolbar} ); return (
    {!['vertical', 'sidebar', 'chrome'].includes(mode) ? (
    {!mobileUI ? this.renderArrow('left') : null}
      {this.renderNavs(true)} {additionBtns} {!isOverflow && toolButtons}
    {!mobileUI ? this.renderArrow('right') : null}
    {isOverflow && toolButtons}
    ) : (
      {this.renderNavs()} {additionBtns} {toolbar}
    )}
    {children.map((child, index) => { return this.renderTab(child, index); })}
    {draggable && (
    )}
    ); } } const ThemedTabs = localeable( themeable( uncontrollable(Tabs, { activeKey: 'onSelect' }) ) ); export default ThemedTabs as typeof ThemedTabs & { Tab: typeof Tab; };