amis-rpc-design/libs/amis/packages/amis-ui/src/components/Tabs.tsx

945 lines
25 KiB
TypeScript
Raw Normal View History

2023-10-07 19:42:30 +08:00
/**
* @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;
};