UNPKG

@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
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 });