@douyinfe/semi-ui
Version:
A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.
738 lines • 26.5 kB
JavaScript
import _isEqual from "lodash/isEqual";
import _isFunction from "lodash/isFunction";
import _isEmpty from "lodash/isEmpty";
import _each from "lodash/each";
import _omit from "lodash/omit";
import _get from "lodash/get";
import _noop from "lodash/noop";
import _throttle from "lodash/throttle";
var __rest = this && this.__rest || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
}
return t;
};
import React, { isValidElement, cloneElement } from 'react';
import ReactDOM, { findDOMNode } from 'react-dom';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { BASE_CLASS_PREFIX } from '@douyinfe/semi-foundation/lib/es/base/constants';
import warning from '@douyinfe/semi-foundation/lib/es/utils/warning';
import Event from '@douyinfe/semi-foundation/lib/es/utils/Event';
import { convertDOMRectToObject } from '@douyinfe/semi-foundation/lib/es/utils/dom';
import TooltipFoundation from '@douyinfe/semi-foundation/lib/es/tooltip/foundation';
import { strings, cssClasses, numbers } from '@douyinfe/semi-foundation/lib/es/tooltip/constants';
import { getUuidShort } from '@douyinfe/semi-foundation/lib/es/utils/uuid';
import '@douyinfe/semi-foundation/lib/es/tooltip/tooltip.css';
import BaseComponent from '../_base/baseComponent';
import { isHTMLElement } from '../_base/reactUtils';
import { getActiveElement, getDefaultPropsFromGlobalConfig, getFocusableElements, runAfterTicks, stopPropagation } from '../_utils';
import Portal from '../_portal/index';
import ConfigContext from '../configProvider/context';
import TriangleArrow from './TriangleArrow';
import TriangleArrowVertical from './TriangleArrowVertical';
import ArrowBoundingShape from './ArrowBoundingShape';
import CSSAnimation from "../_cssAnimation";
const prefix = cssClasses.PREFIX;
const positionSet = strings.POSITION_SET;
const triggerSet = strings.TRIGGER_SET;
const blockDisplays = ['flex', 'block', 'table', 'flow-root', 'grid'];
const defaultGetContainer = () => document.body;
export default class Tooltip extends BaseComponent {
constructor(props) {
super(props);
this.isAnimating = false;
this.setContainerEl = node => this.containerEl = {
current: node
};
this.isSpecial = elem => {
if (isHTMLElement(elem)) {
return Boolean(elem.disabled);
} else if (/*#__PURE__*/isValidElement(elem)) {
const disabled = _get(elem, 'props.disabled');
if (disabled) {
return strings.STATUS_DISABLED;
}
const loading = _get(elem, 'props.loading');
/* Only judge the loading state of the Button, and no longer judge other components */
const isButton = !_isEmpty(elem) && !_isEmpty(elem.type) && (_get(elem, 'type.elementType') === 'Button' || _get(elem, 'type.elementType') === 'IconButton');
if (loading && isButton) {
return strings.STATUS_LOADING;
}
}
return false;
};
// willEnter = () => {
// this.foundation.calcPosition();
// this.setState({ visible: true });
// };
this.didLeave = () => {
if (this.props.keepDOM) {
this.foundation.setDisplayNone(true);
} else {
this.foundation.removePortal();
}
this.foundation.unBindEvent();
};
this.renderIcon = () => {
const {
placement
} = this.state;
const {
showArrow,
prefixCls,
style
} = this.props;
let icon = null;
const triangleCls = classNames([`${prefixCls}-icon-arrow`]);
const bgColor = _get(style, 'backgroundColor');
const iconComponent = (placement === null || placement === void 0 ? void 0 : placement.includes('left')) || (placement === null || placement === void 0 ? void 0 : placement.includes('right')) ? /*#__PURE__*/React.createElement(TriangleArrowVertical, null) : /*#__PURE__*/React.createElement(TriangleArrow, null);
if (showArrow) {
if (/*#__PURE__*/isValidElement(showArrow)) {
icon = showArrow;
} else {
icon = /*#__PURE__*/React.cloneElement(iconComponent, {
className: triangleCls,
style: {
color: bgColor,
fill: 'currentColor'
}
});
}
}
return icon;
};
this.handlePortalInnerClick = e => {
if (this.props.clickToHide) {
this.foundation.hide();
}
if (this.props.stopPropagation) {
stopPropagation(e);
}
};
this.handlePortalMouseDown = e => {
if (this.props.stopPropagation) {
stopPropagation(e);
}
};
this.handlePortalFocus = e => {
if (this.props.stopPropagation) {
stopPropagation(e);
}
};
this.handlePortalBlur = e => {
if (this.props.stopPropagation) {
stopPropagation(e);
}
};
this.handlePortalInnerKeyDown = e => {
this.foundation.handleContainerKeydown(e);
};
this.renderContentNode = content => {
const contentProps = {
initialFocusRef: this.initialFocusRef
};
return !_isFunction(content) ? content : content(contentProps);
};
this.renderPortal = () => {
const {
containerStyle = {},
visible,
portalEventSet,
placement,
displayNone,
transitionState,
id,
isPositionUpdated
} = this.state;
const {
prefixCls,
content,
showArrow,
style,
motion,
role,
zIndex
} = this.props;
const contentNode = this.renderContentNode(content);
const {
className: propClassName
} = this.props;
const direction = this.context.direction;
const className = classNames(propClassName, {
[`${prefixCls}-wrapper`]: true,
[`${prefixCls}-wrapper-show`]: visible,
[`${prefixCls}-with-arrow`]: Boolean(showArrow),
[`${prefixCls}-rtl`]: direction === 'rtl'
});
const icon = this.renderIcon();
const portalInnerStyle = _omit(containerStyle, motion ? ['transformOrigin'] : undefined);
const transformOrigin = _get(containerStyle, 'transformOrigin');
const userOpacity = _get(style, 'opacity', null);
const opacity = userOpacity ? userOpacity : 1;
const inner = /*#__PURE__*/React.createElement(CSSAnimation, {
fillMode: "forwards",
animationState: transitionState,
motion: motion && isPositionUpdated,
startClassName: transitionState === 'enter' ? `${prefix}-animation-show` : `${prefix}-animation-hide`,
onAnimationStart: () => this.isAnimating = true,
onAnimationEnd: () => {
var _a, _b;
if (transitionState === 'leave') {
this.didLeave();
(_b = (_a = this.props).afterClose) === null || _b === void 0 ? void 0 : _b.call(_a);
}
this.isAnimating = false;
}
}, _ref => {
let {
animationStyle,
animationClassName,
animationEventsNeedBind
} = _ref;
return /*#__PURE__*/React.createElement("div", Object.assign({
className: classNames(className, animationClassName),
style: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, animationStyle), displayNone ? {
display: "none"
} : {}), {
transformOrigin
}), style), userOpacity ? {
opacity: isPositionUpdated ? opacity : "0"
} : {})
}, portalEventSet, animationEventsNeedBind, {
role: role,
"x-placement": placement,
id: id
}), /*#__PURE__*/React.createElement("div", {
className: `${prefix}-content`
}, contentNode), icon);
});
return /*#__PURE__*/React.createElement(Portal, {
getPopupContainer: this.props.getPopupContainer,
style: {
zIndex
}
}, /*#__PURE__*/React.createElement("div", {
// listen keyboard event, don't move tabIndex -1
tabIndex: -1,
className: `${BASE_CLASS_PREFIX}-portal-inner`,
style: portalInnerStyle,
ref: this.setContainerEl,
onClick: this.handlePortalInnerClick,
onFocus: this.handlePortalFocus,
onBlur: this.handlePortalBlur,
onMouseDown: this.handlePortalMouseDown,
onKeyDown: this.handlePortalInnerKeyDown
}, inner));
};
this.wrapSpan = elem => {
const {
wrapperClassName
} = this.props;
const display = _get(elem, 'props.style.display');
const block = _get(elem, 'props.block');
const isStringElem = typeof elem == 'string';
const style = {};
if (!isStringElem) {
style.display = 'inline-block';
}
if (block || blockDisplays.includes(display)) {
style.width = '100%';
}
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
return /*#__PURE__*/React.createElement("span", {
className: wrapperClassName,
style: style
}, elem);
};
this.mergeEvents = (rawEvents, events) => {
const mergedEvents = {};
_each(events, (handler, key) => {
if (typeof handler === 'function') {
mergedEvents[key] = function () {
handler(...arguments);
if (rawEvents && typeof rawEvents[key] === 'function') {
rawEvents[key](...arguments);
}
};
}
});
return mergedEvents;
};
this.getPopupId = () => {
return this.state.id;
};
this.state = {
visible: false,
/**
*
* Note: The transitionState parameter is equivalent to isInsert
*/
transitionState: '',
triggerEventSet: {},
portalEventSet: {},
containerStyle: {
// zIndex: props.zIndex,
},
isInsert: false,
placement: props.position || 'top',
transitionStyle: {},
isPositionUpdated: false,
id: props.wrapperId,
displayNone: false
};
this.foundation = new TooltipFoundation(this.adapter);
this.eventManager = new Event();
this.triggerEl = /*#__PURE__*/React.createRef();
this.containerEl = /*#__PURE__*/React.createRef();
this.initialFocusRef = /*#__PURE__*/React.createRef();
this.clickOutsideHandler = null;
this.resizeHandler = null;
this.isWrapped = false; // Identifies whether a span element is wrapped
this.containerPosition = undefined;
}
get adapter() {
var _this = this;
return Object.assign(Object.assign({}, super.adapter), {
// @ts-ignore
on: function () {
return _this.eventManager.on(...arguments);
},
// @ts-ignore
off: function () {
return _this.eventManager.off(...arguments);
},
getAnimatingState: () => this.isAnimating,
insertPortal: (content, _a) => {
var {
position
} = _a,
containerStyle = __rest(_a, ["position"]);
this.setState({
isInsert: true,
transitionState: 'enter',
containerStyle: Object.assign(Object.assign({}, this.state.containerStyle), containerStyle)
}, () => {
setTimeout(() => {
this.setState(oldState => {
if (oldState.transitionState === 'enter') {
this.eventManager.emit('portalInserted');
}
return {};
});
// waiting child component mounted
}, 0);
});
},
removePortal: () => {
this.setState({
isInsert: false,
isPositionUpdated: false
});
},
getEventName: () => ({
mouseEnter: 'onMouseEnter',
mouseLeave: 'onMouseLeave',
mouseOut: 'onMouseOut',
mouseOver: 'onMouseOver',
click: 'onClick',
focus: 'onFocus',
blur: 'onBlur',
keydown: 'onKeyDown',
contextMenu: 'onContextMenu'
}),
registerTriggerEvent: triggerEventSet => {
this.setState({
triggerEventSet
});
},
registerPortalEvent: portalEventSet => {
this.setState({
portalEventSet
});
},
getTriggerBounding: () => {
// It may be a React component or an html element
// There is no guarantee that triggerE l.current can get the real dom, so call findDOMNode to ensure that you can get the real dom
const triggerDOM = this.adapter.getTriggerNode();
this.triggerEl.current = triggerDOM;
return triggerDOM && triggerDOM.getBoundingClientRect();
},
// Gets the outer size of the specified container
getPopupContainerRect: () => {
const container = this.getPopupContainer();
let rect = null;
if (container && isHTMLElement(container)) {
const boundingRect = convertDOMRectToObject(container.getBoundingClientRect());
rect = Object.assign(Object.assign({}, boundingRect), {
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop
});
}
return rect;
},
containerIsBody: () => {
const container = this.getPopupContainer();
return container === document.body;
},
containerIsRelative: () => {
const container = this.getPopupContainer();
const computedStyle = window.getComputedStyle(container);
return computedStyle.getPropertyValue('position') === 'relative';
},
containerIsRelativeOrAbsolute: () => ['relative', 'absolute'].includes(this.containerPosition),
// Get the size of the pop-up layer
getWrapperBounding: () => {
const el = this.containerEl && this.containerEl.current;
return el && el.getBoundingClientRect();
},
getDocumentElementBounding: () => document.documentElement.getBoundingClientRect(),
setPosition: _a => {
var {
position
} = _a,
style = __rest(_a, ["position"]);
this.setState({
containerStyle: Object.assign(Object.assign({}, this.state.containerStyle), style),
placement: position,
isPositionUpdated: true
}, () => {
this.eventManager.emit('positionUpdated');
});
},
setDisplayNone: (displayNone, cb) => {
this.setState({
displayNone
}, cb);
},
updatePlacementAttr: placement => {
this.setState({
placement
});
},
togglePortalVisible: (visible, cb) => {
const willUpdateStates = {};
willUpdateStates.transitionState = visible ? 'enter' : 'leave';
willUpdateStates.visible = visible;
this.mounted && this.setState(willUpdateStates, () => {
cb();
});
},
registerClickOutsideHandler: cb => {
if (this.clickOutsideHandler) {
this.adapter.unregisterClickOutsideHandler();
}
this.clickOutsideHandler = e => {
if (!this.mounted) {
return false;
}
let el = this.triggerEl && this.triggerEl.current;
let popupEl = this.containerEl && this.containerEl.current;
el = ReactDOM.findDOMNode(el);
popupEl = ReactDOM.findDOMNode(popupEl);
const target = e.target;
const path = e.composedPath && e.composedPath() || [target];
const isClickTriggerToHide = this.props.clickTriggerToHide ? el && el.contains(target) || path.includes(el) : false;
if (el && !el.contains(target) && popupEl && !popupEl.contains(target) && !(path.includes(popupEl) || path.includes(el)) || isClickTriggerToHide) {
this.props.onClickOutSide(e);
cb();
}
};
window.addEventListener('mousedown', this.clickOutsideHandler);
},
unregisterClickOutsideHandler: () => {
if (this.clickOutsideHandler) {
window.removeEventListener('mousedown', this.clickOutsideHandler);
this.clickOutsideHandler = null;
}
},
registerResizeHandler: cb => {
if (this.resizeHandler) {
this.adapter.unregisterResizeHandler();
}
this.resizeHandler = _throttle(e => {
if (!this.mounted) {
return false;
}
cb(e);
}, 10);
window.addEventListener('resize', this.resizeHandler, false);
},
unregisterResizeHandler: () => {
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler, false);
this.resizeHandler = null;
}
},
notifyVisibleChange: visible => {
this.props.onVisibleChange(visible);
},
registerScrollHandler: rePositionCb => {
if (this.scrollHandler) {
this.adapter.unregisterScrollHandler();
}
this.scrollHandler = _throttle(e => {
if (!this.mounted) {
return false;
}
const triggerDOM = this.adapter.getTriggerNode();
const isRelativeScroll = e.target.contains(triggerDOM);
if (isRelativeScroll) {
const scrollPos = {
x: e.target.scrollLeft,
y: e.target.scrollTop
};
rePositionCb(scrollPos);
}
}, 10); // When it is greater than 16ms, it will be very obvious
window.addEventListener('scroll', this.scrollHandler, true);
},
unregisterScrollHandler: () => {
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler, true);
this.scrollHandler = null;
}
},
canMotion: () => Boolean(this.props.motion),
updateContainerPosition: () => {
const positionInBody = document.body.getAttribute('data-position');
if (positionInBody) {
this.containerPosition = positionInBody;
return;
}
requestAnimationFrame(() => {
const container = this.getPopupContainer();
if (container && isHTMLElement(container)) {
// getComputedStyle need first parameter is Element type
const computedStyle = window.getComputedStyle(container);
const position = computedStyle.getPropertyValue('position');
document.body.setAttribute('data-position', position);
this.containerPosition = position;
}
});
},
getContainerPosition: () => this.containerPosition,
getContainer: () => this.containerEl && this.containerEl.current,
getTriggerNode: () => {
let triggerDOM = this.triggerEl.current;
if (!isHTMLElement(this.triggerEl.current)) {
triggerDOM = ReactDOM.findDOMNode(this.triggerEl.current);
}
return triggerDOM;
},
getFocusableElements: node => {
return getFocusableElements(node);
},
getActiveElement: () => {
return getActiveElement();
},
setInitialFocus: () => {
const {
preventScroll
} = this.props;
const focusRefNode = _get(this, 'initialFocusRef.current');
if (focusRefNode && 'focus' in focusRefNode) {
focusRefNode.focus({
preventScroll
});
}
},
notifyEscKeydown: event => {
this.props.onEscKeyDown(event);
},
setId: () => {
this.setState({
id: getUuidShort()
});
},
getTriggerDOM: () => {
if (this.triggerEl.current) {
return ReactDOM.findDOMNode(this.triggerEl.current);
} else {
return null;
}
}
});
}
componentDidMount() {
this.mounted = true;
this.getPopupContainer = this.props.getPopupContainer || this.context.getPopupContainer || defaultGetContainer;
this.foundation.init();
runAfterTicks(() => {
let triggerEle = this.triggerEl.current;
if (triggerEle) {
if (!(triggerEle instanceof HTMLElement)) {
triggerEle = findDOMNode(triggerEle);
}
}
this.foundation.updateStateIfCursorOnTrigger(triggerEle);
}, 1);
}
componentWillUnmount() {
this.mounted = false;
this.foundation.destroy();
}
/**
* focus on tooltip trigger
*/
focusTrigger() {
this.foundation.focusTrigger();
}
/** for transition - end */
rePosition() {
return this.foundation.calcPosition();
}
componentDidUpdate(prevProps, prevState) {
warning(this.props.mouseLeaveDelay < this.props.mouseEnterDelay, "[Semi Tooltip] 'mouseLeaveDelay' cannot be less than 'mouseEnterDelay', which may cause the dropdown layer to not be hidden.");
if (prevProps.visible !== this.props.visible) {
if (['hover', 'focus'].includes(this.props.trigger)) {
this.props.visible ? this.foundation.delayShow() : this.foundation.delayHide();
} else {
this.props.visible ? this.foundation.show() : this.foundation.hide();
}
}
if (!_isEqual(prevProps.rePosKey, this.props.rePosKey)) {
this.rePosition();
}
}
render() {
const {
isInsert,
triggerEventSet,
visible,
id
} = this.state;
const {
wrapWhenSpecial,
role,
trigger
} = this.props;
let {
children
} = this.props;
const childrenStyle = Object.assign({}, _get(children, 'props.style'));
const extraStyle = {};
if (wrapWhenSpecial) {
const isSpecial = this.isSpecial(children);
if (isSpecial) {
childrenStyle.pointerEvents = 'none';
if (isSpecial === strings.STATUS_DISABLED) {
extraStyle.cursor = 'not-allowed';
}
children = /*#__PURE__*/cloneElement(children, {
style: childrenStyle
});
if (trigger !== 'custom') {
// no need to wrap span when trigger is custom, cause it don't need bind event
children = this.wrapSpan(children);
}
this.isWrapped = true;
} else if (! /*#__PURE__*/isValidElement(children)) {
children = this.wrapSpan(children);
this.isWrapped = true;
}
}
let ariaAttribute = {};
// Take effect when used by Popover component
if (role === 'dialog') {
ariaAttribute['aria-expanded'] = visible ? 'true' : 'false';
ariaAttribute['aria-haspopup'] = 'dialog';
ariaAttribute['aria-controls'] = id;
} else {
ariaAttribute['aria-describedby'] = id;
}
// The incoming children is a single valid element, otherwise wrap a layer with span
const newChild = /*#__PURE__*/React.cloneElement(children, Object.assign(Object.assign(Object.assign(Object.assign({}, ariaAttribute), children.props), this.mergeEvents(children.props, triggerEventSet)), {
style: Object.assign(Object.assign({}, _get(children, 'props.style')), extraStyle),
className: classNames(_get(children, 'props.className')),
// to maintain refs with callback
ref: node => {
// Keep your own reference
this.triggerEl.current = node;
// Call the original ref, if any
const {
ref
} = children;
// this.log('tooltip render() - get ref', ref);
if (typeof ref === 'function') {
ref(node);
} else if (ref && typeof ref === 'object') {
ref.current = node;
}
},
tabIndex: children.props.tabIndex || 0,
'data-popupid': id
}));
// If you do not add a layer of div, in order to bind the events and className in the tooltip, you need to cloneElement children, but this time it may overwrite the children's original ref reference
// So if the user adds ref to the content, you need to use callback ref: https://github.com/facebook/react/issues/8873
return /*#__PURE__*/React.createElement(React.Fragment, null, isInsert ? this.renderPortal() : null, newChild);
}
}
Tooltip.contextType = ConfigContext;
Tooltip.propTypes = {
children: PropTypes.node,
motion: PropTypes.bool,
autoAdjustOverflow: PropTypes.bool,
position: PropTypes.oneOf(positionSet),
getPopupContainer: PropTypes.func,
mouseEnterDelay: PropTypes.number,
mouseLeaveDelay: PropTypes.number,
trigger: PropTypes.oneOf(triggerSet).isRequired,
className: PropTypes.string,
wrapperClassName: PropTypes.string,
clickToHide: PropTypes.bool,
// used with trigger === hover, private
clickTriggerToHide: PropTypes.bool,
visible: PropTypes.bool,
style: PropTypes.object,
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
prefixCls: PropTypes.string,
onVisibleChange: PropTypes.func,
onClickOutSide: PropTypes.func,
spacing: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
margin: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
showArrow: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]),
zIndex: PropTypes.number,
rePosKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
arrowBounding: ArrowBoundingShape,
transformFromCenter: PropTypes.bool,
arrowPointAtCenter: PropTypes.bool,
stopPropagation: PropTypes.bool,
// private
role: PropTypes.string,
wrapWhenSpecial: PropTypes.bool,
guardFocus: PropTypes.bool,
returnFocusOnClose: PropTypes.bool,
preventScroll: PropTypes.bool,
keepDOM: PropTypes.bool
};
Tooltip.__SemiComponentName__ = "Tooltip";
Tooltip.defaultProps = getDefaultPropsFromGlobalConfig(Tooltip.__SemiComponentName__, {
arrowBounding: numbers.ARROW_BOUNDING,
autoAdjustOverflow: true,
arrowPointAtCenter: true,
trigger: 'hover',
transformFromCenter: true,
position: 'top',
prefixCls: prefix,
role: 'tooltip',
mouseEnterDelay: numbers.MOUSE_ENTER_DELAY,
mouseLeaveDelay: numbers.MOUSE_LEAVE_DELAY,
motion: true,
onVisibleChange: _noop,
onClickOutSide: _noop,
spacing: numbers.SPACING,
margin: numbers.MARGIN,
showArrow: true,
wrapWhenSpecial: true,
zIndex: numbers.DEFAULT_Z_INDEX,
closeOnEsc: false,
guardFocus: false,
returnFocusOnClose: false,
onEscKeyDown: _noop,
disableFocusListener: false,
disableArrowKeyDown: false,
keepDOM: false
});