945 lines
25 KiB
TypeScript
945 lines
25 KiB
TypeScript
|
/**
|
|||
|
* @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<React.ReactNode>;
|
|||
|
swipeable?: boolean;
|
|||
|
onSelect?: (eventKey: string | number) => void;
|
|||
|
}
|
|||
|
|
|||
|
class TabComponent extends React.PureComponent<TabProps> {
|
|||
|
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 (
|
|||
|
<Transition
|
|||
|
in={activeKey === eventKey}
|
|||
|
mountOnEnter={mountOnEnter}
|
|||
|
unmountOnExit={typeof reload === 'boolean' ? reload : unmountOnExit}
|
|||
|
timeout={500}
|
|||
|
>
|
|||
|
{(status: string) => {
|
|||
|
if (status === ENTERING) {
|
|||
|
this.contentDom.offsetWidth;
|
|||
|
}
|
|||
|
return (
|
|||
|
<div
|
|||
|
ref={this.contentRef}
|
|||
|
className={cx(
|
|||
|
transitionStyles[status],
|
|||
|
activeKey === eventKey ? 'is-active' : '',
|
|||
|
'Tabs-pane',
|
|||
|
className
|
|||
|
)}
|
|||
|
onTouchStart={swipeable && mobileUI ? this.onTouchStart : noop}
|
|||
|
onTouchMove={swipeable && mobileUI ? this.onTouchMove : noop}
|
|||
|
onTouchEnd={swipeable && mobileUI ? this.onTouchEnd : noop}
|
|||
|
onTouchCancel={swipeable && mobileUI ? this.onTouchEnd : noop}
|
|||
|
>
|
|||
|
{children}
|
|||
|
</div>
|
|||
|
);
|
|||
|
}}
|
|||
|
</Transition>
|
|||
|
);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
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<TabProps>;
|
|||
|
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<React.ReactNode>;
|
|||
|
}
|
|||
|
|
|||
|
export interface IDragInfo {
|
|||
|
nodeId: string;
|
|||
|
}
|
|||
|
|
|||
|
export class Tabs extends React.Component<TabsProps, any> {
|
|||
|
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<HTMLUListElement>(); // HTMLDivElement
|
|||
|
scroll: boolean = false;
|
|||
|
sortable?: Sortable;
|
|||
|
dragTip?: HTMLElement;
|
|||
|
id: string = guid();
|
|||
|
draging: boolean = false;
|
|||
|
toDispose: Array<() => void> = [];
|
|||
|
resizeDom = React.createRef<HTMLDivElement>();
|
|||
|
|
|||
|
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<HTMLInputElement>) {
|
|||
|
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 = <Icon cx={cx} icon={icon} className="Icon" />;
|
|||
|
|
|||
|
const link = (
|
|||
|
<a title={typeof title === 'string' ? title : undefined}>
|
|||
|
{editable && editingIndex === index ? (
|
|||
|
<input
|
|||
|
className={cx('Tabs-link-edit')}
|
|||
|
type="text"
|
|||
|
value={editInputText}
|
|||
|
autoFocus
|
|||
|
onFocus={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|||
|
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}
|
|||
|
</>
|
|||
|
)}
|
|||
|
</a>
|
|||
|
);
|
|||
|
|
|||
|
return (
|
|||
|
<li
|
|||
|
className={cx(
|
|||
|
'Tabs-link',
|
|||
|
activeKey === eventKey ? 'is-active' : '',
|
|||
|
disabled ? 'is-disabled' : '',
|
|||
|
tabClassName
|
|||
|
)}
|
|||
|
key={this.generateTabKey(hash, eventKey, index)}
|
|||
|
onClick={() => (disabled ? '' : this.handleSelect(eventKey))}
|
|||
|
onDoubleClick={() => {
|
|||
|
editable &&
|
|||
|
typeof title === 'string' &&
|
|||
|
this.handleStartEdit(index, title);
|
|||
|
}}
|
|||
|
>
|
|||
|
{showTip ? (
|
|||
|
<TooltipWrapper
|
|||
|
placement="top"
|
|||
|
tooltip={tip ?? (typeof title === 'string' ? title : '')}
|
|||
|
trigger="hover"
|
|||
|
tooltipClassName={showTipClassName}
|
|||
|
>
|
|||
|
{link}
|
|||
|
</TooltipWrapper>
|
|||
|
) : (
|
|||
|
link
|
|||
|
)}
|
|||
|
|
|||
|
{showClose && (tabClosable ?? closable) && (
|
|||
|
<span
|
|||
|
className={cx('Tabs-link-close')}
|
|||
|
onClick={(e: React.MouseEvent) => {
|
|||
|
e.stopPropagation();
|
|||
|
this.props.onClose &&
|
|||
|
this.props.onClose(index, eventKey ?? index);
|
|||
|
}}
|
|||
|
>
|
|||
|
<Icon icon="close" className={cx('Tabs-link-close-icon')} />
|
|||
|
</span>
|
|||
|
)}
|
|||
|
{mode === 'chrome' ? (
|
|||
|
<div className="chrome-tab-background">
|
|||
|
<svg viewBox="0 0 124 124" className="chrome-tab-background--right">
|
|||
|
<path d="M0,0 C0,68.483309 55.516691,124 124,124 L0,124 L0,-1 C0.00132103964,-0.667821298 0,-0.334064922 0,0 Z"></path>
|
|||
|
</svg>
|
|||
|
<svg viewBox="0 0 124 124" className="chrome-tab-background--left">
|
|||
|
<path d="M124,0 L124,125 L0,125 L0,125 C68.483309,125 124,69.483309 124,1 L123.992,0 L124,0 Z"></path>
|
|||
|
</svg>
|
|||
|
</div>
|
|||
|
) : null}
|
|||
|
</li>
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
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 ? (
|
|||
|
<div
|
|||
|
onClick={() => this.handleArrow(type)}
|
|||
|
className={cx(
|
|||
|
'Tabs-linksContainer-arrow',
|
|||
|
'Tabs-linksContainer-arrow--' + type,
|
|||
|
disabled && 'Tabs-linksContainer-arrow--disabled'
|
|||
|
)}
|
|||
|
>
|
|||
|
<i className={'iconfont icon-arrow-' + type} />
|
|||
|
</div>
|
|||
|
) : 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<any> = (children as Array<any>).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(
|
|||
|
<PopOverContainer
|
|||
|
key="togglor"
|
|||
|
placement="center-bottom-center-top center-top-center-bottom"
|
|||
|
popOverClassName={cx('Tabs-PopOver')}
|
|||
|
popOverContainer={popOverContainer || (() => findDOMNode(this))}
|
|||
|
popOverRender={({onClose}) => (
|
|||
|
<ul
|
|||
|
className={cx('Tabs-PopOverList', 'DropDown-menu')}
|
|||
|
onClick={onClose}
|
|||
|
>
|
|||
|
{rest}
|
|||
|
</ul>
|
|||
|
)}
|
|||
|
>
|
|||
|
{({onClick, ref, isOpened}) => (
|
|||
|
<li
|
|||
|
className={cx(
|
|||
|
'Tabs-link',
|
|||
|
rest.some(item => ~item.props.className.indexOf('is-active'))
|
|||
|
? 'is-active'
|
|||
|
: ''
|
|||
|
)}
|
|||
|
>
|
|||
|
<a
|
|||
|
className={cx('Tabs-togglor', isOpened ? 'is-opened' : '')}
|
|||
|
onClick={onClick}
|
|||
|
>
|
|||
|
<span>{__(collapseBtnLabel || 'more')}</span>
|
|||
|
<span className={cx('Tabs-togglor-arrow')}>
|
|||
|
<Icon icon="right-arrow-bold" className="icon" />
|
|||
|
</span>
|
|||
|
</a>
|
|||
|
</li>
|
|||
|
)}
|
|||
|
</PopOverContainer>
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
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 && (
|
|||
|
<div
|
|||
|
className={cx('Tabs-addable')}
|
|||
|
onClick={() => this.handleAddBtn()}
|
|||
|
>
|
|||
|
<Icon icon="plus" className={cx('Tabs-addable-icon')} />
|
|||
|
{addBtnText}
|
|||
|
</div>
|
|||
|
)}
|
|||
|
{toolbar}
|
|||
|
</>
|
|||
|
);
|
|||
|
|
|||
|
return (
|
|||
|
<div
|
|||
|
className={cx(
|
|||
|
`Tabs`,
|
|||
|
{
|
|||
|
[`Tabs--${mode}`]: mode,
|
|||
|
[`sidebar--${sidePosition}`]: mode === 'sidebar'
|
|||
|
},
|
|||
|
className
|
|||
|
)}
|
|||
|
style={style}
|
|||
|
>
|
|||
|
{!['vertical', 'sidebar', 'chrome'].includes(mode) ? (
|
|||
|
<div
|
|||
|
className={cx(
|
|||
|
'Tabs-linksContainer-wrapper',
|
|||
|
toolbar && 'Tabs-linksContainer-wrapper--toolbar'
|
|||
|
)}
|
|||
|
ref={this.resizeDom}
|
|||
|
>
|
|||
|
<div
|
|||
|
className={cx(
|
|||
|
'Tabs-linksContainer',
|
|||
|
isOverflow && 'Tabs-linksContainer--overflow'
|
|||
|
)}
|
|||
|
>
|
|||
|
{!mobileUI ? this.renderArrow('left') : null}
|
|||
|
<div className={cx('Tabs-linksContainer-main')}>
|
|||
|
<ul
|
|||
|
className={cx('Tabs-links', linksClassName, {
|
|||
|
'is-mobile': mobileUI
|
|||
|
})}
|
|||
|
role="tablist"
|
|||
|
ref={this.navMain}
|
|||
|
>
|
|||
|
{this.renderNavs(true)}
|
|||
|
{additionBtns}
|
|||
|
{!isOverflow && toolButtons}
|
|||
|
</ul>
|
|||
|
</div>
|
|||
|
{!mobileUI ? this.renderArrow('right') : null}
|
|||
|
</div>
|
|||
|
{isOverflow && toolButtons}
|
|||
|
</div>
|
|||
|
) : (
|
|||
|
<div className={cx('Tabs-linksWrapper')}>
|
|||
|
<ul
|
|||
|
className={cx('Tabs-links', linksClassName, {
|
|||
|
'is-mobile': mobileUI
|
|||
|
})}
|
|||
|
role="tablist"
|
|||
|
>
|
|||
|
{this.renderNavs()}
|
|||
|
{additionBtns}
|
|||
|
{toolbar}
|
|||
|
</ul>
|
|||
|
</div>
|
|||
|
)}
|
|||
|
|
|||
|
<div className={cx('Tabs-content', contentClassName)}>
|
|||
|
{children.map((child, index) => {
|
|||
|
return this.renderTab(child, index);
|
|||
|
})}
|
|||
|
</div>
|
|||
|
{draggable && (
|
|||
|
<div className={cx('Tabs-drag-tip')} ref={this.dragTipRef} />
|
|||
|
)}
|
|||
|
</div>
|
|||
|
);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const ThemedTabs = localeable(
|
|||
|
themeable(
|
|||
|
uncontrollable(Tabs, {
|
|||
|
activeKey: 'onSelect'
|
|||
|
})
|
|||
|
)
|
|||
|
);
|
|||
|
|
|||
|
export default ThemedTabs as typeof ThemedTabs & {
|
|||
|
Tab: typeof Tab;
|
|||
|
};
|