index.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. import React, { isValidElement, cloneElement, CSSProperties, ReactInstance } from 'react';
  2. import ReactDOM, { findDOMNode } from 'react-dom';
  3. import classNames from 'classnames';
  4. import PropTypes from 'prop-types';
  5. import { throttle, noop, get, omit, each, isEmpty, isFunction, isEqual } from 'lodash';
  6. import { BASE_CLASS_PREFIX } from '@douyinfe/semi-foundation/base/constants';
  7. import warning from '@douyinfe/semi-foundation/utils/warning';
  8. import Event from '@douyinfe/semi-foundation/utils/Event';
  9. import { ArrayElement } from '@douyinfe/semi-foundation/utils/type';
  10. import { convertDOMRectToObject, DOMRectLikeType } from '@douyinfe/semi-foundation/utils/dom';
  11. import TooltipFoundation, {
  12. TooltipAdapter,
  13. Position,
  14. PopupContainerDOMRect
  15. } from '@douyinfe/semi-foundation/tooltip/foundation';
  16. import { strings, cssClasses, numbers } from '@douyinfe/semi-foundation/tooltip/constants';
  17. import { getUuidShort } from '@douyinfe/semi-foundation/utils/uuid';
  18. import '@douyinfe/semi-foundation/tooltip/tooltip.scss';
  19. import BaseComponent, { BaseProps } from '../_base/baseComponent';
  20. import { isHTMLElement } from '../_base/reactUtils';
  21. import {
  22. getActiveElement,
  23. getDefaultPropsFromGlobalConfig,
  24. getFocusableElements,
  25. runAfterTicks,
  26. stopPropagation,
  27. } from '../_utils';
  28. import Portal from '../_portal/index';
  29. import ConfigContext, { ContextValue } from '../configProvider/context';
  30. import TriangleArrow from './TriangleArrow';
  31. import TriangleArrowVertical from './TriangleArrowVertical';
  32. import ArrowBoundingShape from './ArrowBoundingShape';
  33. import CSSAnimation from "../_cssAnimation";
  34. export type Trigger = ArrayElement<typeof strings.TRIGGER_SET>;
  35. export type { Position };
  36. export interface ArrowBounding {
  37. offsetX?: number;
  38. offsetY?: number;
  39. width?: number;
  40. height?: number
  41. }
  42. export interface RenderContentProps<T = HTMLElement> {
  43. initialFocusRef?: React.RefObject<T>
  44. }
  45. export type RenderContent<T = HTMLElement> = (props: RenderContentProps<T>) => React.ReactNode;
  46. export interface TooltipProps extends BaseProps {
  47. children?: React.ReactNode;
  48. motion?: boolean;
  49. autoAdjustOverflow?: boolean;
  50. position?: Position;
  51. getPopupContainer?: () => HTMLElement;
  52. mouseEnterDelay?: number;
  53. mouseLeaveDelay?: number;
  54. trigger?: Trigger;
  55. className?: string;
  56. clickToHide?: boolean;
  57. visible?: boolean;
  58. style?: React.CSSProperties;
  59. content?: React.ReactNode | RenderContent;
  60. prefixCls?: string;
  61. onVisibleChange?: (visible: boolean) => void;
  62. onClickOutSide?: (e: React.MouseEvent) => void;
  63. spacing?: number | { x: number; y: number };
  64. margin?: number | { marginLeft: number; marginTop: number; marginRight: number; marginBottom: number };
  65. showArrow?: boolean | React.ReactNode;
  66. zIndex?: number;
  67. rePosKey?: string | number;
  68. role?: string;
  69. arrowBounding?: ArrowBounding;
  70. transformFromCenter?: boolean;
  71. arrowPointAtCenter?: boolean;
  72. wrapWhenSpecial?: boolean;
  73. stopPropagation?: boolean;
  74. clickTriggerToHide?: boolean;
  75. wrapperClassName?: string;
  76. closeOnEsc?: boolean;
  77. guardFocus?: boolean;
  78. returnFocusOnClose?: boolean;
  79. onEscKeyDown?: (e: React.KeyboardEvent) => void;
  80. disableArrowKeyDown?: boolean;
  81. wrapperId?: string;
  82. preventScroll?: boolean;
  83. disableFocusListener?: boolean;
  84. afterClose?: () => void;
  85. keepDOM?: boolean
  86. }
  87. interface TooltipState {
  88. visible: boolean;
  89. transitionState: string;
  90. triggerEventSet: {
  91. [key: string]: any
  92. };
  93. portalEventSet: {
  94. [key: string]: any
  95. };
  96. containerStyle: React.CSSProperties;
  97. isInsert: boolean;
  98. placement: Position;
  99. transitionStyle: Record<string, any>;
  100. isPositionUpdated: boolean;
  101. id: string;
  102. displayNone: boolean
  103. }
  104. const prefix = cssClasses.PREFIX;
  105. const positionSet = strings.POSITION_SET;
  106. const triggerSet = strings.TRIGGER_SET;
  107. const blockDisplays = ['flex', 'block', 'table', 'flow-root', 'grid'];
  108. const defaultGetContainer = () => document.body;
  109. export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
  110. static contextType = ConfigContext;
  111. static propTypes = {
  112. children: PropTypes.node,
  113. motion: PropTypes.bool,
  114. autoAdjustOverflow: PropTypes.bool,
  115. position: PropTypes.oneOf(positionSet),
  116. getPopupContainer: PropTypes.func,
  117. mouseEnterDelay: PropTypes.number,
  118. mouseLeaveDelay: PropTypes.number,
  119. trigger: PropTypes.oneOf(triggerSet).isRequired,
  120. className: PropTypes.string,
  121. wrapperClassName: PropTypes.string,
  122. clickToHide: PropTypes.bool,
  123. // used with trigger === hover, private
  124. clickTriggerToHide: PropTypes.bool,
  125. visible: PropTypes.bool,
  126. style: PropTypes.object,
  127. content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
  128. prefixCls: PropTypes.string,
  129. onVisibleChange: PropTypes.func,
  130. onClickOutSide: PropTypes.func,
  131. spacing: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
  132. margin: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
  133. showArrow: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]),
  134. zIndex: PropTypes.number,
  135. rePosKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  136. arrowBounding: ArrowBoundingShape,
  137. transformFromCenter: PropTypes.bool, // Whether to change from the center of the trigger (for dynamic effects)
  138. arrowPointAtCenter: PropTypes.bool,
  139. stopPropagation: PropTypes.bool,
  140. // private
  141. role: PropTypes.string,
  142. wrapWhenSpecial: PropTypes.bool, // when trigger has special status such as "disabled" or "loading", wrap span
  143. guardFocus: PropTypes.bool,
  144. returnFocusOnClose: PropTypes.bool,
  145. preventScroll: PropTypes.bool,
  146. keepDOM: PropTypes.bool,
  147. };
  148. static __SemiComponentName__ = "Tooltip";
  149. static defaultProps = getDefaultPropsFromGlobalConfig(Tooltip.__SemiComponentName__, {
  150. arrowBounding: numbers.ARROW_BOUNDING,
  151. autoAdjustOverflow: true,
  152. arrowPointAtCenter: true,
  153. trigger: 'hover',
  154. transformFromCenter: true,
  155. position: 'top',
  156. prefixCls: prefix,
  157. role: 'tooltip',
  158. mouseEnterDelay: numbers.MOUSE_ENTER_DELAY,
  159. mouseLeaveDelay: numbers.MOUSE_LEAVE_DELAY,
  160. motion: true,
  161. onVisibleChange: noop,
  162. onClickOutSide: noop,
  163. spacing: numbers.SPACING,
  164. margin: numbers.MARGIN,
  165. showArrow: true,
  166. wrapWhenSpecial: true,
  167. zIndex: numbers.DEFAULT_Z_INDEX,
  168. closeOnEsc: false,
  169. guardFocus: false,
  170. returnFocusOnClose: false,
  171. onEscKeyDown: noop,
  172. disableFocusListener: false,
  173. disableArrowKeyDown: false,
  174. keepDOM: false
  175. });
  176. eventManager: Event;
  177. triggerEl: React.RefObject<unknown>;
  178. containerEl: React.RefObject<HTMLDivElement>;
  179. initialFocusRef: React.RefObject<HTMLElement>;
  180. clickOutsideHandler: any;
  181. resizeHandler: any;
  182. isWrapped: boolean;
  183. mounted: any;
  184. scrollHandler: any;
  185. getPopupContainer: () => HTMLElement;
  186. containerPosition: string;
  187. foundation: TooltipFoundation;
  188. context: ContextValue;
  189. isAnimating: boolean = false;
  190. constructor(props: TooltipProps) {
  191. super(props);
  192. this.state = {
  193. visible: false,
  194. /**
  195. *
  196. * Note: The transitionState parameter is equivalent to isInsert
  197. */
  198. transitionState: '',
  199. triggerEventSet: {},
  200. portalEventSet: {},
  201. containerStyle: {
  202. // zIndex: props.zIndex,
  203. },
  204. isInsert: false,
  205. placement: props.position || 'top',
  206. transitionStyle: {},
  207. isPositionUpdated: false,
  208. id: props.wrapperId, // auto generate id, will be used by children.aria-describedby & content.id, improve a11y,
  209. displayNone: false
  210. };
  211. this.foundation = new TooltipFoundation(this.adapter);
  212. this.eventManager = new Event();
  213. this.triggerEl = React.createRef();
  214. this.containerEl = React.createRef();
  215. this.initialFocusRef = React.createRef();
  216. this.clickOutsideHandler = null;
  217. this.resizeHandler = null;
  218. this.isWrapped = false; // Identifies whether a span element is wrapped
  219. this.containerPosition = undefined;
  220. }
  221. setContainerEl = (node: HTMLDivElement) => (this.containerEl = { current: node });
  222. get adapter(): TooltipAdapter<TooltipProps, TooltipState> {
  223. return {
  224. ...super.adapter,
  225. // @ts-ignore
  226. on: (...args: any[]) => this.eventManager.on(...args),
  227. // @ts-ignore
  228. off: (...args: any[]) => this.eventManager.off(...args),
  229. getAnimatingState: () => this.isAnimating,
  230. insertPortal: (content: TooltipProps['content'], { position, ...containerStyle }: { position: Position }) => {
  231. this.setState(
  232. {
  233. isInsert: true,
  234. transitionState: 'enter',
  235. containerStyle: { ...this.state.containerStyle, ...containerStyle },
  236. },
  237. () => {
  238. setTimeout(() => {
  239. this.setState((oldState) => {
  240. if ( oldState.transitionState === 'enter' ) {
  241. this.eventManager.emit('portalInserted');
  242. }
  243. return {};
  244. });
  245. // waiting child component mounted
  246. }, 0);
  247. }
  248. );
  249. },
  250. removePortal: () => {
  251. this.setState({ isInsert: false, isPositionUpdated: false });
  252. },
  253. getEventName: () => ({
  254. mouseEnter: 'onMouseEnter',
  255. mouseLeave: 'onMouseLeave',
  256. mouseOut: 'onMouseOut',
  257. mouseOver: 'onMouseOver',
  258. click: 'onClick',
  259. focus: 'onFocus',
  260. blur: 'onBlur',
  261. keydown: 'onKeyDown',
  262. contextMenu: 'onContextMenu',
  263. }),
  264. registerTriggerEvent: (triggerEventSet: Record<string, any>) => {
  265. this.setState({ triggerEventSet });
  266. },
  267. registerPortalEvent: (portalEventSet: Record<string, any>) => {
  268. this.setState({ portalEventSet });
  269. },
  270. getTriggerBounding: () => {
  271. // It may be a React component or an html element
  272. // 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
  273. const triggerDOM = this.adapter.getTriggerNode();
  274. (this.triggerEl as any).current = triggerDOM;
  275. return triggerDOM && (triggerDOM as Element).getBoundingClientRect();
  276. },
  277. // Gets the outer size of the specified container
  278. getPopupContainerRect: () => {
  279. const container = this.getPopupContainer();
  280. let rect: PopupContainerDOMRect = null;
  281. if (container && isHTMLElement(container)) {
  282. const boundingRect: DOMRectLikeType = convertDOMRectToObject(container.getBoundingClientRect());
  283. rect = {
  284. ...boundingRect,
  285. scrollLeft: container.scrollLeft,
  286. scrollTop: container.scrollTop,
  287. };
  288. }
  289. return rect;
  290. },
  291. containerIsBody: () => {
  292. const container = this.getPopupContainer();
  293. return container === document.body;
  294. },
  295. containerIsRelative: () => {
  296. const container = this.getPopupContainer();
  297. const computedStyle = window.getComputedStyle(container);
  298. return computedStyle.getPropertyValue('position') === 'relative';
  299. },
  300. containerIsRelativeOrAbsolute: () => ['relative', 'absolute'].includes(this.containerPosition),
  301. // Get the size of the pop-up layer
  302. getWrapperBounding: () => {
  303. const el = this.containerEl && this.containerEl.current;
  304. return el && (el as Element).getBoundingClientRect();
  305. },
  306. getDocumentElementBounding: () => document.documentElement.getBoundingClientRect(),
  307. setPosition: ({ position, ...style }: { position: Position }) => {
  308. this.setState(
  309. {
  310. containerStyle: { ...this.state.containerStyle, ...style },
  311. placement: position,
  312. isPositionUpdated: true
  313. },
  314. () => {
  315. this.eventManager.emit('positionUpdated');
  316. }
  317. );
  318. },
  319. setDisplayNone: (displayNone: boolean, cb: () => void) => {
  320. this.setState({ displayNone }, cb);
  321. },
  322. updatePlacementAttr: (placement: Position) => {
  323. this.setState({ placement });
  324. },
  325. togglePortalVisible: (visible: boolean, cb: () => void) => {
  326. const willUpdateStates: Partial<TooltipState> = {};
  327. willUpdateStates.transitionState = visible ? 'enter' : 'leave';
  328. willUpdateStates.visible = visible;
  329. this.mounted && this.setState(willUpdateStates as TooltipState, () => {
  330. cb();
  331. });
  332. },
  333. registerClickOutsideHandler: (cb: () => void) => {
  334. if (this.clickOutsideHandler) {
  335. this.adapter.unregisterClickOutsideHandler();
  336. }
  337. this.clickOutsideHandler = (e: React.MouseEvent): any => {
  338. if (!this.mounted) {
  339. return false;
  340. }
  341. let el = this.triggerEl && this.triggerEl.current;
  342. let popupEl = this.containerEl && this.containerEl.current;
  343. el = ReactDOM.findDOMNode(el as React.ReactInstance);
  344. popupEl = ReactDOM.findDOMNode(popupEl as React.ReactInstance) as HTMLDivElement;
  345. const target = e.target as Element;
  346. const path = (e as any).composedPath && (e as any).composedPath() || [target];
  347. const isClickTriggerToHide = this.props.clickTriggerToHide ? el && (el as any).contains(target) || path.includes(el) : false;
  348. if (
  349. el && !(el as any).contains(target) &&
  350. popupEl && !(popupEl as any).contains(target) &&
  351. !(path.includes(popupEl) || path.includes(el)) ||
  352. isClickTriggerToHide
  353. ) {
  354. this.props.onClickOutSide(e);
  355. cb();
  356. }
  357. };
  358. window.addEventListener('mousedown', this.clickOutsideHandler);
  359. },
  360. unregisterClickOutsideHandler: () => {
  361. if (this.clickOutsideHandler) {
  362. window.removeEventListener('mousedown', this.clickOutsideHandler);
  363. this.clickOutsideHandler = null;
  364. }
  365. },
  366. registerResizeHandler: (cb: (e: any) => void) => {
  367. if (this.resizeHandler) {
  368. this.adapter.unregisterResizeHandler();
  369. }
  370. this.resizeHandler = throttle((e): any => {
  371. if (!this.mounted) {
  372. return false;
  373. }
  374. cb(e);
  375. }, 10);
  376. window.addEventListener('resize', this.resizeHandler, false);
  377. },
  378. unregisterResizeHandler: () => {
  379. if (this.resizeHandler) {
  380. window.removeEventListener('resize', this.resizeHandler, false);
  381. this.resizeHandler = null;
  382. }
  383. },
  384. notifyVisibleChange: (visible: boolean) => {
  385. this.props.onVisibleChange(visible);
  386. },
  387. registerScrollHandler: (rePositionCb: (arg: { x: number; y: number }) => void) => {
  388. if (this.scrollHandler) {
  389. this.adapter.unregisterScrollHandler();
  390. }
  391. this.scrollHandler = throttle((e): any => {
  392. if (!this.mounted) {
  393. return false;
  394. }
  395. const triggerDOM = this.adapter.getTriggerNode();
  396. const isRelativeScroll = e.target.contains(triggerDOM);
  397. if (isRelativeScroll) {
  398. const scrollPos = { x: e.target.scrollLeft, y: e.target.scrollTop };
  399. rePositionCb(scrollPos);
  400. }
  401. }, 10); // When it is greater than 16ms, it will be very obvious
  402. window.addEventListener('scroll', this.scrollHandler, true);
  403. },
  404. unregisterScrollHandler: () => {
  405. if (this.scrollHandler) {
  406. window.removeEventListener('scroll', this.scrollHandler, true);
  407. this.scrollHandler = null;
  408. }
  409. },
  410. canMotion: () => Boolean(this.props.motion),
  411. updateContainerPosition: () => {
  412. const positionInBody = document.body.getAttribute('data-position');
  413. if (positionInBody) {
  414. this.containerPosition = positionInBody;
  415. return;
  416. }
  417. requestAnimationFrame(() => {
  418. const container = this.getPopupContainer();
  419. if (container && isHTMLElement(container)) {
  420. // getComputedStyle need first parameter is Element type
  421. const computedStyle = window.getComputedStyle(container);
  422. const position = computedStyle.getPropertyValue('position');
  423. document.body.setAttribute('data-position', position);
  424. this.containerPosition = position;
  425. }
  426. });
  427. },
  428. getContainerPosition: () => this.containerPosition,
  429. getContainer: () => this.containerEl && this.containerEl.current,
  430. getTriggerNode: () => {
  431. let triggerDOM = this.triggerEl.current;
  432. if (!isHTMLElement(this.triggerEl.current)) {
  433. triggerDOM = ReactDOM.findDOMNode(this.triggerEl.current as React.ReactInstance);
  434. }
  435. return triggerDOM as Element;
  436. },
  437. getFocusableElements: (node: HTMLDivElement) => {
  438. return getFocusableElements(node);
  439. },
  440. getActiveElement: () => {
  441. return getActiveElement();
  442. },
  443. setInitialFocus: () => {
  444. const { preventScroll } = this.props;
  445. const focusRefNode = get(this, 'initialFocusRef.current') as HTMLElement;
  446. if (focusRefNode && 'focus' in focusRefNode) {
  447. focusRefNode.focus({ preventScroll });
  448. }
  449. },
  450. notifyEscKeydown: (event: React.KeyboardEvent) => {
  451. this.props.onEscKeyDown(event);
  452. },
  453. setId: () => {
  454. this.setState({ id: getUuidShort() });
  455. },
  456. getTriggerDOM: () => {
  457. if (this.triggerEl.current) {
  458. return ReactDOM.findDOMNode(this.triggerEl.current as ReactInstance) as HTMLElement;
  459. } else {
  460. return null;
  461. }
  462. }
  463. };
  464. }
  465. componentDidMount() {
  466. this.mounted = true;
  467. this.getPopupContainer = this.props.getPopupContainer || this.context.getPopupContainer || defaultGetContainer;
  468. this.foundation.init();
  469. runAfterTicks(() => {
  470. let triggerEle = this.triggerEl.current;
  471. if (triggerEle) {
  472. if (!(triggerEle instanceof HTMLElement)) {
  473. triggerEle = findDOMNode(triggerEle as ReactInstance);
  474. }
  475. }
  476. this.foundation.updateStateIfCursorOnTrigger(triggerEle as HTMLElement);
  477. }, 1);
  478. }
  479. componentWillUnmount() {
  480. this.mounted = false;
  481. this.foundation.destroy();
  482. }
  483. /**
  484. * focus on tooltip trigger
  485. */
  486. public focusTrigger() {
  487. this.foundation.focusTrigger();
  488. }
  489. isSpecial = (elem: React.ReactNode | HTMLElement | any) => {
  490. if (isHTMLElement(elem)) {
  491. return Boolean(elem.disabled);
  492. } else if (isValidElement(elem)) {
  493. const disabled = get(elem, 'props.disabled');
  494. if (disabled) {
  495. return strings.STATUS_DISABLED;
  496. }
  497. const loading = get(elem, 'props.loading');
  498. /* Only judge the loading state of the Button, and no longer judge other components */
  499. const isButton = !isEmpty(elem)
  500. && !isEmpty(elem.type)
  501. && (get(elem, 'type.elementType') === 'Button' || get(elem, 'type.elementType') === 'IconButton');
  502. if (loading && isButton) {
  503. return strings.STATUS_LOADING;
  504. }
  505. }
  506. return false;
  507. };
  508. // willEnter = () => {
  509. // this.foundation.calcPosition();
  510. // this.setState({ visible: true });
  511. // };
  512. didLeave = () => {
  513. if (this.props.keepDOM) {
  514. this.foundation.setDisplayNone(true);
  515. } else {
  516. this.foundation.removePortal();
  517. }
  518. this.foundation.unBindEvent();
  519. };
  520. /** for transition - end */
  521. rePosition() {
  522. return this.foundation.calcPosition();
  523. }
  524. componentDidUpdate(prevProps: TooltipProps, prevState: TooltipState) {
  525. warning(
  526. this.props.mouseLeaveDelay < this.props.mouseEnterDelay,
  527. "[Semi Tooltip] 'mouseLeaveDelay' cannot be less than 'mouseEnterDelay', which may cause the dropdown layer to not be hidden."
  528. );
  529. if (prevProps.visible !== this.props.visible) {
  530. if (['hover', 'focus'].includes(this.props.trigger)) {
  531. this.props.visible ? this.foundation.delayShow() : this.foundation.delayHide();
  532. } else {
  533. this.props.visible ? this.foundation.show() : this.foundation.hide();
  534. }
  535. }
  536. if (!isEqual(prevProps.rePosKey, this.props.rePosKey)) {
  537. this.rePosition();
  538. }
  539. }
  540. renderIcon = () => {
  541. const { placement } = this.state;
  542. const { showArrow, prefixCls, style } = this.props;
  543. let icon = null;
  544. const triangleCls = classNames([`${prefixCls}-icon-arrow`]);
  545. const bgColor = get(style, 'backgroundColor');
  546. const iconComponent = placement?.includes('left') || placement?.includes('right') ?
  547. <TriangleArrowVertical /> :
  548. <TriangleArrow />;
  549. if (showArrow) {
  550. if (isValidElement(showArrow)) {
  551. icon = showArrow;
  552. } else {
  553. icon = React.cloneElement(iconComponent, {
  554. className: triangleCls,
  555. style: { color: bgColor, fill: 'currentColor' }
  556. });
  557. }
  558. }
  559. return icon;
  560. };
  561. handlePortalInnerClick = (e: React.MouseEvent) => {
  562. if (this.props.clickToHide) {
  563. this.foundation.hide();
  564. }
  565. if (this.props.stopPropagation) {
  566. stopPropagation(e);
  567. }
  568. };
  569. handlePortalMouseDown = (e: React.MouseEvent) => {
  570. if (this.props.stopPropagation) {
  571. stopPropagation(e);
  572. }
  573. }
  574. handlePortalFocus = (e: React.FocusEvent<HTMLElement>) => {
  575. if (this.props.stopPropagation) {
  576. stopPropagation(e);
  577. }
  578. }
  579. handlePortalBlur = (e: React.FocusEvent<HTMLElement>) => {
  580. if (this.props.stopPropagation) {
  581. stopPropagation(e);
  582. }
  583. }
  584. handlePortalInnerKeyDown = (e: React.KeyboardEvent) => {
  585. this.foundation.handleContainerKeydown(e);
  586. }
  587. renderContentNode = (content: TooltipProps['content']) => {
  588. const contentProps = {
  589. initialFocusRef: this.initialFocusRef
  590. };
  591. return !isFunction(content) ? content : content(contentProps);
  592. };
  593. renderPortal = () => {
  594. const {
  595. containerStyle = {},
  596. visible,
  597. portalEventSet,
  598. placement,
  599. displayNone,
  600. transitionState,
  601. id,
  602. isPositionUpdated
  603. } = this.state;
  604. const { prefixCls, content, showArrow, style, motion, role, zIndex } = this.props;
  605. const contentNode = this.renderContentNode(content);
  606. const { className: propClassName } = this.props;
  607. const direction = this.context.direction;
  608. const className = classNames(propClassName, {
  609. [`${prefixCls}-wrapper`]: true,
  610. [`${prefixCls}-wrapper-show`]: visible,
  611. [`${prefixCls}-with-arrow`]: Boolean(showArrow),
  612. [`${prefixCls}-rtl`]: direction === 'rtl',
  613. });
  614. const icon = this.renderIcon();
  615. const portalInnerStyle = omit(containerStyle, motion ? ['transformOrigin'] : undefined);
  616. const transformOrigin = get(containerStyle, 'transformOrigin');
  617. const userOpacity: CSSProperties['opacity'] | null = get(style, 'opacity', null);
  618. const opacity = userOpacity ? userOpacity : 1;
  619. const inner =
  620. <CSSAnimation
  621. fillMode="forwards"
  622. animationState={transitionState as "enter" | "leave"}
  623. motion={motion && isPositionUpdated}
  624. startClassName={transitionState === 'enter' ? `${prefix}-animation-show` : `${prefix}-animation-hide`}
  625. onAnimationStart={() => this.isAnimating = true}
  626. onAnimationEnd={() => {
  627. if (transitionState === 'leave') {
  628. this.didLeave();
  629. this.props.afterClose?.();
  630. }
  631. this.isAnimating = false;
  632. }}>
  633. {
  634. ({ animationStyle, animationClassName, animationEventsNeedBind }) => {
  635. return <div
  636. className={classNames(className, animationClassName)}
  637. style={{
  638. ...animationStyle,
  639. ...(displayNone ? { display: "none" } : {}),
  640. transformOrigin,
  641. ...style,
  642. ...(userOpacity ? { opacity: isPositionUpdated ? opacity : "0" } : {})
  643. }}
  644. {...portalEventSet}
  645. {...animationEventsNeedBind}
  646. role={role}
  647. x-placement={placement}
  648. id={id}
  649. >
  650. <div className={`${prefix}-content`} >{contentNode}</div>
  651. {icon}
  652. </div>;
  653. }
  654. }
  655. </CSSAnimation>;
  656. return (
  657. <Portal getPopupContainer={this.props.getPopupContainer} style={{ zIndex }}>
  658. {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
  659. <div
  660. // listen keyboard event, don't move tabIndex -1
  661. tabIndex={-1}
  662. className={`${BASE_CLASS_PREFIX}-portal-inner`}
  663. style={portalInnerStyle}
  664. ref={this.setContainerEl}
  665. onClick={this.handlePortalInnerClick}
  666. onFocus={this.handlePortalFocus}
  667. onBlur={this.handlePortalBlur}
  668. onMouseDown={this.handlePortalMouseDown}
  669. onKeyDown={this.handlePortalInnerKeyDown}
  670. >
  671. {inner}
  672. </div>
  673. </Portal>
  674. );
  675. };
  676. wrapSpan = (elem: React.ReactNode | React.ReactElement) => {
  677. const { wrapperClassName } = this.props;
  678. const display = get(elem, 'props.style.display');
  679. const block = get(elem, 'props.block');
  680. const isStringElem = typeof elem == 'string';
  681. const style: React.CSSProperties = {};
  682. if (!isStringElem) {
  683. style.display = 'inline-block';
  684. }
  685. if (block || blockDisplays.includes(display)) {
  686. style.width = '100%';
  687. }
  688. // eslint-disable-next-line jsx-a11y/no-static-element-interactions
  689. return <span className={wrapperClassName} style={style}>{elem}</span>;
  690. };
  691. mergeEvents = (rawEvents: Record<string, any>, events: Record<string, any>) => {
  692. const mergedEvents = {};
  693. each(events, (handler: any, key) => {
  694. if (typeof handler === 'function') {
  695. mergedEvents[key] = (...args: any[]) => {
  696. handler(...args);
  697. if (rawEvents && typeof rawEvents[key] === 'function') {
  698. rawEvents[key](...args);
  699. }
  700. };
  701. }
  702. });
  703. return mergedEvents;
  704. };
  705. getPopupId = () => {
  706. return this.state.id;
  707. }
  708. render() {
  709. const { isInsert, triggerEventSet, visible, id } = this.state;
  710. const { wrapWhenSpecial, role, trigger } = this.props;
  711. let { children } = this.props;
  712. const childrenStyle = { ...get(children, 'props.style') as React.CSSProperties };
  713. const extraStyle: React.CSSProperties = {};
  714. if (wrapWhenSpecial) {
  715. const isSpecial = this.isSpecial(children);
  716. if (isSpecial) {
  717. childrenStyle.pointerEvents = 'none';
  718. if (isSpecial === strings.STATUS_DISABLED) {
  719. extraStyle.cursor = 'not-allowed';
  720. }
  721. children = cloneElement(children as React.ReactElement, { style: childrenStyle });
  722. if (trigger !== 'custom') {
  723. // no need to wrap span when trigger is custom, cause it don't need bind event
  724. children = this.wrapSpan(children);
  725. }
  726. this.isWrapped = true;
  727. } else if (!isValidElement(children)) {
  728. children = this.wrapSpan(children);
  729. this.isWrapped = true;
  730. }
  731. }
  732. let ariaAttribute = {};
  733. // Take effect when used by Popover component
  734. if (role === 'dialog') {
  735. ariaAttribute['aria-expanded'] = visible ? 'true' : 'false';
  736. ariaAttribute['aria-haspopup'] = 'dialog';
  737. ariaAttribute['aria-controls'] = id;
  738. } else {
  739. ariaAttribute['aria-describedby'] = id;
  740. }
  741. // The incoming children is a single valid element, otherwise wrap a layer with span
  742. const newChild = React.cloneElement(children as React.ReactElement, {
  743. ...ariaAttribute,
  744. ...(children as React.ReactElement).props,
  745. ...this.mergeEvents((children as React.ReactElement).props, triggerEventSet),
  746. style: {
  747. ...get(children, 'props.style') as React.CSSProperties,
  748. ...extraStyle,
  749. },
  750. className: classNames(
  751. get(children, 'props.className')
  752. ),
  753. // to maintain refs with callback
  754. ref: (node: React.ReactNode) => {
  755. // Keep your own reference
  756. (this.triggerEl as any).current = node;
  757. // Call the original ref, if any
  758. const { ref } = children as any;
  759. // this.log('tooltip render() - get ref', ref);
  760. if (typeof ref === 'function') {
  761. ref(node);
  762. } else if (ref && typeof ref === 'object') {
  763. ref.current = node;
  764. }
  765. },
  766. tabIndex: (children as React.ReactElement).props.tabIndex || 0, // a11y keyboard, in some condition select's tabindex need to -1 or 0
  767. 'data-popupid': id
  768. });
  769. // 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
  770. // So if the user adds ref to the content, you need to use callback ref: https://github.com/facebook/react/issues/8873
  771. return (
  772. <React.Fragment>
  773. {isInsert ? this.renderPortal() : null}
  774. {newChild}
  775. </React.Fragment>
  776. );
  777. }
  778. }