foundation.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. /* eslint-disable no-param-reassign */
  2. /* eslint-disable prefer-destructuring, max-lines-per-function, one-var, max-len, @typescript-eslint/restrict-plus-operands */
  3. /* argus-disable unPkgSensitiveInfo */
  4. import { get, isEmpty } from 'lodash';
  5. import { DOMRectLikeType } from '../utils/dom';
  6. import BaseFoundation, { DefaultAdapter } from '../base/foundation';
  7. import { ArrayElement } from '../utils/type';
  8. import { strings } from './constants';
  9. import { handlePrevent } from '../utils/a11y';
  10. const REGS = {
  11. TOP: /top/i,
  12. RIGHT: /right/i,
  13. BOTTOM: /bottom/i,
  14. LEFT: /left/i,
  15. };
  16. const defaultRect = {
  17. left: 0,
  18. top: 0,
  19. height: 0,
  20. width: 0,
  21. scrollLeft: 0,
  22. scrollTop: 0,
  23. };
  24. export interface TooltipAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
  25. registerPortalEvent(portalEventSet: any): void;
  26. unregisterPortalEvent(): void;
  27. registerResizeHandler(onResize: () => void): void;
  28. unregisterResizeHandler(onResize?: () => void): void;
  29. on(arg0: string, arg1: () => void): void;
  30. notifyVisibleChange(isVisible: any): void;
  31. getPopupContainerRect(): PopupContainerDOMRect;
  32. containerIsBody(): boolean;
  33. off(arg0: string): void;
  34. canMotion(): boolean;
  35. registerScrollHandler(arg: () => Record<string, any>): void;
  36. unregisterScrollHandler(): void;
  37. insertPortal(...args: any[]): void;
  38. removePortal(...args: any[]): void;
  39. getEventName(): {
  40. mouseEnter: string;
  41. mouseLeave: string;
  42. mouseOut: string;
  43. mouseOver: string;
  44. click: string;
  45. focus: string;
  46. blur: string;
  47. keydown: string;
  48. };
  49. registerTriggerEvent(...args: any[]): void;
  50. getTriggerBounding(...args: any[]): DOMRect;
  51. getWrapperBounding(...args: any[]): DOMRect;
  52. setPosition(...args: any[]): void;
  53. togglePortalVisible(...args: any[]): void;
  54. registerClickOutsideHandler(...args: any[]): void;
  55. unregisterClickOutsideHandler(...args: any[]): void;
  56. unregisterTriggerEvent(): void;
  57. containerIsRelative(): boolean;
  58. containerIsRelativeOrAbsolute(): boolean;
  59. getDocumentElementBounding(): DOMRect;
  60. updateContainerPosition(): void;
  61. updatePlacementAttr(placement: Position): void;
  62. getContainerPosition(): string;
  63. getFocusableElements(node: any): any[];
  64. getActiveElement(): any;
  65. getContainer(): any;
  66. setInitialFocus(): void;
  67. notifyEscKeydown(event: any): void;
  68. getTriggerNode(): any;
  69. setId(): void;
  70. }
  71. export type Position = ArrayElement<typeof strings.POSITION_SET>;
  72. export interface PopupContainerDOMRect extends DOMRectLikeType {
  73. scrollLeft?: number;
  74. scrollTop?: number;
  75. }
  76. export default class Tooltip<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<TooltipAdapter<P, S>, P, S> {
  77. _timer: ReturnType<typeof setTimeout>;
  78. _mounted: boolean;
  79. constructor(adapter: TooltipAdapter<P, S>) {
  80. super({ ...adapter });
  81. this._timer = null;
  82. }
  83. init() {
  84. const { wrapperId } = this.getProps();
  85. this._mounted = true;
  86. this._bindEvent();
  87. this._shouldShow();
  88. this._initContainerPosition();
  89. if (!wrapperId) {
  90. this._adapter.setId();
  91. }
  92. }
  93. destroy() {
  94. this._mounted = false;
  95. this._unBindEvent();
  96. }
  97. _bindEvent() {
  98. const trigger = this.getProp('trigger'); // get trigger type
  99. const { triggerEventSet, portalEventSet } = this._generateEvent(trigger);
  100. this._bindTriggerEvent(triggerEventSet);
  101. this._bindPortalEvent(portalEventSet);
  102. this._bindResizeEvent();
  103. }
  104. _unBindEvent() {
  105. this._unBindTriggerEvent();
  106. this._unBindPortalEvent();
  107. this._unBindResizeEvent();
  108. this._unBindScrollEvent();
  109. }
  110. _bindTriggerEvent(triggerEventSet: Record<string, any>) {
  111. this._adapter.registerTriggerEvent(triggerEventSet);
  112. }
  113. _unBindTriggerEvent() {
  114. this._adapter.unregisterTriggerEvent();
  115. }
  116. _bindPortalEvent(portalEventSet: Record<string, any>) {
  117. this._adapter.registerPortalEvent(portalEventSet);
  118. }
  119. _unBindPortalEvent() {
  120. this._adapter.unregisterPortalEvent();
  121. }
  122. _bindResizeEvent() {
  123. this._adapter.registerResizeHandler(this.onResize);
  124. }
  125. _unBindResizeEvent() {
  126. this._adapter.unregisterResizeHandler(this.onResize);
  127. }
  128. _reversePos(position = '', isVertical = false) {
  129. if (isVertical) {
  130. if (REGS.TOP.test(position)) {
  131. return position.replace('top', 'bottom').replace('Top', 'Bottom');
  132. } else if (REGS.BOTTOM.test(position)) {
  133. return position.replace('bottom', 'top').replace('Bottom', 'Top');
  134. }
  135. } else if (REGS.LEFT.test(position)) {
  136. return position.replace('left', 'right').replace('Left', 'Right');
  137. } else if (REGS.RIGHT.test(position)) {
  138. return position.replace('right', 'left').replace('Right', 'Left');
  139. }
  140. return position;
  141. }
  142. clearDelayTimer() {
  143. if (this._timer) {
  144. clearTimeout(this._timer);
  145. this._timer = null;
  146. }
  147. }
  148. _generateEvent(types: ArrayElement<typeof strings.TRIGGER_SET>) {
  149. const eventNames = this._adapter.getEventName();
  150. const triggerEventSet = {
  151. // bind esc keydown on trigger for a11y
  152. [eventNames.keydown]: (event) => {
  153. this._handleTriggerKeydown(event);
  154. },
  155. };
  156. let portalEventSet = {};
  157. switch (types) {
  158. case 'focus':
  159. triggerEventSet[eventNames.focus] = () => {
  160. this.delayShow();
  161. };
  162. triggerEventSet[eventNames.blur] = () => {
  163. this.delayHide();
  164. };
  165. portalEventSet = triggerEventSet;
  166. break;
  167. case 'click':
  168. triggerEventSet[eventNames.click] = () => {
  169. // this.delayShow();
  170. this.show();
  171. };
  172. portalEventSet = {};
  173. // Click outside needs special treatment, can not be directly tied to the trigger Element, need to be bound to the document
  174. break;
  175. case 'hover':
  176. triggerEventSet[eventNames.mouseEnter] = () => {
  177. // console.log(e);
  178. this.setCache('isClickToHide', false);
  179. this.delayShow();
  180. // this.show('trigger');
  181. };
  182. triggerEventSet[eventNames.mouseLeave] = () => {
  183. // console.log(e);
  184. this.delayHide();
  185. // this.hide('trigger');
  186. };
  187. // bind focus to hover trigger for a11y
  188. triggerEventSet[eventNames.focus] = () => {
  189. this.delayShow();
  190. };
  191. triggerEventSet[eventNames.blur] = () => {
  192. this.delayHide();
  193. };
  194. portalEventSet = { ...triggerEventSet };
  195. if (this.getProp('clickToHide')) {
  196. portalEventSet[eventNames.click] = () => {
  197. this.setCache('isClickToHide', true);
  198. this.hide();
  199. };
  200. portalEventSet[eventNames.mouseEnter] = () => {
  201. if (this.getCache('isClickToHide')) {
  202. return;
  203. }
  204. this.delayShow();
  205. };
  206. }
  207. break;
  208. case 'custom':
  209. // when trigger type is 'custom', no need to bind eventHandler
  210. // show/hide completely depend on props.visible which change by user
  211. break;
  212. default:
  213. break;
  214. }
  215. return { triggerEventSet, portalEventSet };
  216. }
  217. onResize = () => {
  218. // this.log('resize');
  219. // rePosition when window resize
  220. this.calcPosition();
  221. };
  222. _shouldShow() {
  223. const visible = this.getProp('visible');
  224. if (visible) {
  225. this.show();
  226. } else {
  227. // this.hide();
  228. }
  229. }
  230. delayShow = () => {
  231. const mouseEnterDelay: number = this.getProp('mouseEnterDelay');
  232. this.clearDelayTimer();
  233. if (mouseEnterDelay > 0) {
  234. this._timer = setTimeout(() => {
  235. this.show();
  236. this.clearDelayTimer();
  237. }, mouseEnterDelay);
  238. } else {
  239. this.show();
  240. }
  241. };
  242. show = () => {
  243. const content = this.getProp('content');
  244. const trigger = this.getProp('trigger');
  245. const clickTriggerToHide = this.getProp('clickTriggerToHide');
  246. this.clearDelayTimer();
  247. /**
  248. * If you emit an event in setState callback, you need to place the event listener function before setState to execute.
  249. * This is to avoid event registration being executed later than setState callback when setState is executed in setTimeout.
  250. * internal-issues:1402#note_38969412
  251. */
  252. this._adapter.on('portalInserted', () => {
  253. this.calcPosition();
  254. });
  255. this._adapter.on('positionUpdated', () => {
  256. this._togglePortalVisible(true);
  257. });
  258. const position = this.calcPosition(null, null, null, false);
  259. this._adapter.insertPortal(content, position);
  260. if (trigger === 'custom') {
  261. // eslint-disable-next-line
  262. this._adapter.registerClickOutsideHandler(() => {});
  263. }
  264. /**
  265. * trigger类型是click时,仅当portal被插入显示后,才绑定clickOutsideHandler
  266. * 因为handler需要绑定在document上。如果在constructor阶段绑定
  267. * 当一个页面中有多个容器实例时,一次click会触发多个容器的handler
  268. *
  269. * When the trigger type is click, clickOutsideHandler is bound only after the portal is inserted and displayed
  270. * Because the handler needs to be bound to the document. If you bind during the constructor phase
  271. * When there are multiple container instances in a page, one click triggers the handler of multiple containers
  272. */
  273. if (trigger === 'click' || clickTriggerToHide) {
  274. this._adapter.registerClickOutsideHandler(this.hide);
  275. }
  276. this._bindScrollEvent();
  277. this._bindResizeEvent();
  278. };
  279. _togglePortalVisible(isVisible: boolean) {
  280. const nowVisible = this.getState('visible');
  281. if (nowVisible !== isVisible) {
  282. this._adapter.togglePortalVisible(isVisible, () => {
  283. if (isVisible) {
  284. this._adapter.setInitialFocus();
  285. }
  286. this._adapter.notifyVisibleChange(isVisible);
  287. });
  288. }
  289. }
  290. _roundPixel(pixel: number) {
  291. if (typeof pixel === 'number') {
  292. return Math.round(pixel);
  293. }
  294. return pixel;
  295. }
  296. calcTransformOrigin(position: Position, triggerRect: DOMRect, translateX: number, translateY: number) {
  297. // eslint-disable-next-line
  298. if (position && triggerRect && translateX != null && translateY != null) {
  299. if (this.getProp('transformFromCenter')) {
  300. if (['topLeft', 'bottomLeft'].includes(position)) {
  301. return `${this._roundPixel(triggerRect.width / 2)}px ${-translateY * 100}%`;
  302. }
  303. if (['topRight', 'bottomRight'].includes(position)) {
  304. return `calc(100% - ${this._roundPixel(triggerRect.width / 2)}px) ${-translateY * 100}%`;
  305. }
  306. if (['leftTop', 'rightTop'].includes(position)) {
  307. return `${-translateX * 100}% ${this._roundPixel(triggerRect.height / 2)}px`;
  308. }
  309. if (['leftBottom', 'rightBottom'].includes(position)) {
  310. return `${-translateX * 100}% calc(100% - ${this._roundPixel(triggerRect.height / 2)}px)`;
  311. }
  312. }
  313. return `${-translateX * 100}% ${-translateY * 100}%`;
  314. }
  315. return null;
  316. }
  317. calcPosStyle(triggerRect: DOMRect, wrapperRect: DOMRect, containerRect: PopupContainerDOMRect, position?: Position, spacing?: number) {
  318. triggerRect = (isEmpty(triggerRect) ? triggerRect : this._adapter.getTriggerBounding()) || { ...defaultRect as any };
  319. containerRect = (isEmpty(containerRect) ? containerRect : this._adapter.getPopupContainerRect()) || {
  320. ...defaultRect,
  321. };
  322. wrapperRect = (isEmpty(wrapperRect) ? wrapperRect : this._adapter.getWrapperBounding()) || { ...defaultRect as any };
  323. // eslint-disable-next-line
  324. position = position != null ? position : this.getProp('position');
  325. // eslint-disable-next-line
  326. const SPACING = spacing != null ? spacing : this.getProp('spacing');
  327. const { arrowPointAtCenter, showArrow, arrowBounding } = this.getProps();
  328. const pointAtCenter = showArrow && arrowPointAtCenter;
  329. const horizontalArrowWidth = get(arrowBounding, 'width', 24);
  330. const verticalArrowHeight = get(arrowBounding, 'width', 24);
  331. const arrowOffsetY = get(arrowBounding, 'offsetY', 0);
  332. const positionOffsetX = 6;
  333. const positionOffsetY = 6;
  334. // You must use left/top when rendering, using right/bottom does not render the element position correctly
  335. // Use left/top + translate to achieve tooltip positioning perfectly without knowing the size of the tooltip expansion layer
  336. let left;
  337. let top;
  338. let translateX = 0; // Container x-direction translation distance
  339. let translateY = 0; // Container y-direction translation distance
  340. const middleX = triggerRect.left + triggerRect.width / 2;
  341. const middleY = triggerRect.top + triggerRect.height / 2;
  342. const offsetXWithArrow = positionOffsetX + horizontalArrowWidth / 2;
  343. const offsetYWithArrow = positionOffsetY + verticalArrowHeight / 2;
  344. switch (position) {
  345. case 'top':
  346. left = middleX;
  347. top = triggerRect.top - SPACING;
  348. translateX = -0.5;
  349. translateY = -1;
  350. break;
  351. case 'topLeft':
  352. left = pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left;
  353. top = triggerRect.top - SPACING;
  354. translateY = -1;
  355. break;
  356. case 'topRight':
  357. left = pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right;
  358. top = triggerRect.top - SPACING;
  359. translateY = -1;
  360. translateX = -1;
  361. break;
  362. case 'left':
  363. left = triggerRect.left - SPACING;
  364. top = middleY;
  365. translateX = -1;
  366. translateY = -0.5;
  367. break;
  368. case 'leftTop':
  369. left = triggerRect.left - SPACING;
  370. top = pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top;
  371. translateX = -1;
  372. break;
  373. case 'leftBottom':
  374. left = triggerRect.left - SPACING;
  375. top = pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom;
  376. translateX = -1;
  377. translateY = -1;
  378. break;
  379. case 'bottom':
  380. left = middleX;
  381. top = triggerRect.top + triggerRect.height + SPACING;
  382. translateX = -0.5;
  383. break;
  384. case 'bottomLeft':
  385. left = pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left;
  386. top = triggerRect.bottom + SPACING;
  387. break;
  388. case 'bottomRight':
  389. left = pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right;
  390. top = triggerRect.bottom + SPACING;
  391. translateX = -1;
  392. break;
  393. case 'right':
  394. left = triggerRect.right + SPACING;
  395. top = middleY;
  396. translateY = -0.5;
  397. break;
  398. case 'rightTop':
  399. left = triggerRect.right + SPACING;
  400. top = pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top;
  401. break;
  402. case 'rightBottom':
  403. left = triggerRect.right + SPACING;
  404. top = pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom;
  405. translateY = -1;
  406. break;
  407. case 'leftTopOver':
  408. left = triggerRect.left - SPACING;
  409. top = triggerRect.top - SPACING;
  410. break;
  411. case 'rightTopOver':
  412. left = triggerRect.right + SPACING;
  413. top = triggerRect.top - SPACING;
  414. translateX = -1;
  415. break;
  416. case 'leftBottomOver':
  417. left = triggerRect.left - SPACING;
  418. top = triggerRect.bottom + SPACING;
  419. translateY = -1;
  420. break;
  421. case 'rightBottomOver':
  422. left = triggerRect.right + SPACING;
  423. top = triggerRect.bottom + SPACING;
  424. translateX = -1;
  425. translateY = -1;
  426. break;
  427. default:
  428. break;
  429. }
  430. const transformOrigin = this.calcTransformOrigin(position, triggerRect, translateX, translateY); // Transform origin
  431. const _containerIsBody = this._adapter.containerIsBody();
  432. // Calculate container positioning relative to window
  433. left = left - containerRect.left;
  434. top = top - containerRect.top;
  435. /**
  436. * container为body时,如果position不为relative或absolute,这时trigger计算出的top/left会根据html定位(initial containing block)
  437. * 此时如果body有margin,则计算出的位置相对于body会有问题 fix issue #1368
  438. *
  439. * When container is body, if position is not relative or absolute, then the top/left calculated by trigger will be positioned according to html
  440. * At this time, if the body has a margin, the calculated position will have a problem relative to the body fix issue #1368
  441. */
  442. if (_containerIsBody && !this._adapter.containerIsRelativeOrAbsolute()) {
  443. const documentEleRect = this._adapter.getDocumentElementBounding();
  444. // Represents the left of the body relative to html
  445. left += containerRect.left - documentEleRect.left;
  446. // Represents the top of the body relative to html
  447. top += containerRect.top - documentEleRect.top;
  448. }
  449. // ContainerRect.scrollLeft to solve the inner scrolling of the container
  450. left = _containerIsBody ? left : left + containerRect.scrollLeft;
  451. top = _containerIsBody ? top : top + containerRect.scrollTop;
  452. const triggerHeight = triggerRect.height;
  453. if (
  454. this.getProp('showArrow') &&
  455. !arrowPointAtCenter &&
  456. triggerHeight <= (verticalArrowHeight / 2 + arrowOffsetY) * 2
  457. ) {
  458. const offsetY = triggerHeight / 2 - (arrowOffsetY + verticalArrowHeight / 2);
  459. if ((position.includes('Top') || position.includes('Bottom')) && !position.includes('Over')) {
  460. top = position.includes('Top') ? top + offsetY : top - offsetY;
  461. }
  462. }
  463. // The left/top value here must be rounded, otherwise it will cause the small triangle to shake
  464. const style: Record<string, string | number> = {
  465. left: this._roundPixel(left),
  466. top: this._roundPixel(top),
  467. };
  468. let transform = '';
  469. // eslint-disable-next-line
  470. if (translateX != null) {
  471. transform += `translateX(${translateX * 100}%) `;
  472. Object.defineProperty(style, 'translateX', {
  473. enumerable: false,
  474. value: translateX,
  475. });
  476. }
  477. // eslint-disable-next-line
  478. if (translateY != null) {
  479. transform += `translateY(${translateY * 100}%) `;
  480. Object.defineProperty(style, 'translateY', {
  481. enumerable: false,
  482. value: translateY,
  483. });
  484. }
  485. // eslint-disable-next-line
  486. if (transformOrigin != null) {
  487. style.transformOrigin = transformOrigin;
  488. }
  489. if (transform) {
  490. style.transform = transform;
  491. }
  492. return style;
  493. }
  494. /**
  495. * 耦合的东西比较多,稍微罗列一下:
  496. *
  497. * - 根据 trigger 和 wrapper 的 boundingClient 计算当前的 left、top、transform-origin
  498. * - 根据当前的 position 和 wrapper 的 boundingClient 决定是否需要自动调整位置
  499. * - 根据当前的 position、trigger 的 boundingClient 以及 motion.handleStyle 调整当前的 style
  500. *
  501. * There are many coupling things, a little list:
  502. *
  503. * - calculate the current left, top, and transfer-origin according to the boundingClient of trigger and wrapper
  504. * - decide whether to automatically adjust the position according to the current position and the boundingClient of wrapper
  505. * - adjust the current style according to the current position, the boundingClient of trigger and motion.handle Style
  506. */
  507. calcPosition = (triggerRect?: DOMRect, wrapperRect?: DOMRect, containerRect?: PopupContainerDOMRect, shouldUpdatePos = true) => {
  508. triggerRect = (isEmpty(triggerRect) ? this._adapter.getTriggerBounding() : triggerRect) || { ...defaultRect as any };
  509. containerRect = (isEmpty(containerRect) ? this._adapter.getPopupContainerRect() : containerRect) || {
  510. ...defaultRect,
  511. };
  512. wrapperRect = (isEmpty(wrapperRect) ? this._adapter.getWrapperBounding() : wrapperRect) || { ...defaultRect as any };
  513. // console.log('containerRect: ', containerRect, 'triggerRect: ', triggerRect, 'wrapperRect: ', wrapperRect);
  514. let style = this.calcPosStyle(triggerRect, wrapperRect, containerRect);
  515. let position = this.getProp('position');
  516. if (this.getProp('autoAdjustOverflow')) {
  517. // console.log('style: ', style, '\ntriggerRect: ', triggerRect, '\nwrapperRect: ', wrapperRect);
  518. const adjustedPos = this.adjustPosIfNeed(position, style, triggerRect, wrapperRect, containerRect);
  519. if (position !== adjustedPos) {
  520. position = adjustedPos;
  521. style = this.calcPosStyle(triggerRect, wrapperRect, containerRect, position);
  522. }
  523. }
  524. if (shouldUpdatePos && this._mounted) {
  525. // this._adapter.updatePlacementAttr(style.position);
  526. this._adapter.setPosition({ ...style, position });
  527. }
  528. return style;
  529. };
  530. isLR(position = '') {
  531. return position.indexOf('left') === 0 || position.indexOf('right') === 0;
  532. }
  533. isTB(position = '') {
  534. return position.indexOf('top') === 0 || position.indexOf('bottom') === 0;
  535. }
  536. // place the dom correctly
  537. adjustPosIfNeed(position: Position | string, style: Record<string, any>, triggerRect: DOMRect, wrapperRect: DOMRect, containerRect: PopupContainerDOMRect) {
  538. const { innerWidth, innerHeight } = window;
  539. const { spacing } = this.getProps();
  540. if (wrapperRect.width > 0 && wrapperRect.height > 0) {
  541. // let clientLeft = left + translateX * wrapperRect.width - containerRect.scrollLeft;
  542. // let clientTop = top + translateY * wrapperRect.height - containerRect.scrollTop;
  543. // if (this._adapter.containerIsBody() || this._adapter.containerIsRelative()) {
  544. // clientLeft += containerRect.left;
  545. // clientTop += containerRect.top;
  546. // }
  547. // const clientRight = clientLeft + wrapperRect.width;
  548. // const clientBottom = clientTop + wrapperRect.height;
  549. // The relative position of the elements on the screen
  550. // https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/tooltip-pic.svg
  551. const clientLeft = triggerRect.left;
  552. const clientRight = triggerRect.right;
  553. const clientTop = triggerRect.top;
  554. const clientBottom = triggerRect.bottom;
  555. const restClientLeft = innerWidth - clientLeft;
  556. const restClientTop = innerHeight - clientTop;
  557. const restClientRight = innerWidth - clientRight;
  558. const restClientBottom = innerHeight - clientBottom;
  559. const widthIsBigger = wrapperRect.width > triggerRect.width;
  560. const heightIsBigger = wrapperRect.height > triggerRect.height;
  561. // The wrapperR ect.top|bottom equivalent cannot be directly used here for comparison, which is easy to cause jitter
  562. const shouldReverseTop = clientTop < wrapperRect.height + spacing && restClientBottom > wrapperRect.height + spacing;
  563. const shouldReverseLeft = clientLeft < wrapperRect.width + spacing && restClientRight > wrapperRect.width + spacing;
  564. const shouldReverseBottom = restClientBottom < wrapperRect.height + spacing && clientTop > wrapperRect.height + spacing;
  565. const shouldReverseRight = restClientRight < wrapperRect.width + spacing && clientLeft > wrapperRect.width + spacing;
  566. const shouldReverseTopOver = restClientTop < wrapperRect.height + spacing && clientBottom > wrapperRect.height + spacing;
  567. const shouldReverseBottomOver = clientBottom < wrapperRect.height + spacing && restClientTop > wrapperRect.height + spacing;
  568. const shouldReverseTopSide = restClientTop < wrapperRect.height && clientBottom > wrapperRect.height;
  569. const shouldReverseBottomSide = clientBottom < wrapperRect.height && restClientTop > wrapperRect.height;
  570. const shouldReverseLeftSide = restClientLeft < wrapperRect.width && clientRight > wrapperRect.width;
  571. const shouldReverseRightSide = clientRight < wrapperRect.width && restClientLeft > wrapperRect.width;
  572. const shouldReverseLeftOver = restClientLeft < wrapperRect.width && clientRight > wrapperRect.width;
  573. const shouldReverseRightOver = clientRight < wrapperRect.width && restClientLeft > wrapperRect.width;
  574. switch (position) {
  575. case 'top':
  576. if (shouldReverseTop) {
  577. position = this._reversePos(position, true);
  578. }
  579. break;
  580. case 'topLeft':
  581. if (shouldReverseTop) {
  582. position = this._reversePos(position, true);
  583. }
  584. if (shouldReverseLeftSide && widthIsBigger) {
  585. position = this._reversePos(position);
  586. }
  587. break;
  588. case 'topRight':
  589. if (shouldReverseTop) {
  590. position = this._reversePos(position, true);
  591. }
  592. if (shouldReverseRightSide && widthIsBigger) {
  593. position = this._reversePos(position);
  594. }
  595. break;
  596. case 'left':
  597. if (shouldReverseLeft) {
  598. position = this._reversePos(position);
  599. }
  600. break;
  601. case 'leftTop':
  602. if (shouldReverseLeft) {
  603. position = this._reversePos(position);
  604. }
  605. if (shouldReverseTopSide && heightIsBigger) {
  606. position = this._reversePos(position, true);
  607. }
  608. break;
  609. case 'leftBottom':
  610. if (shouldReverseLeft) {
  611. position = this._reversePos(position);
  612. }
  613. if (shouldReverseBottomSide && heightIsBigger) {
  614. position = this._reversePos(position, true);
  615. }
  616. break;
  617. case 'bottom':
  618. if (shouldReverseBottom) {
  619. position = this._reversePos(position, true);
  620. }
  621. break;
  622. case 'bottomLeft':
  623. if (shouldReverseBottom) {
  624. position = this._reversePos(position, true);
  625. }
  626. if (shouldReverseLeftSide && widthIsBigger) {
  627. position = this._reversePos(position);
  628. }
  629. break;
  630. case 'bottomRight':
  631. if (shouldReverseBottom) {
  632. position = this._reversePos(position, true);
  633. }
  634. if (shouldReverseRightSide && widthIsBigger) {
  635. position = this._reversePos(position);
  636. }
  637. break;
  638. case 'right':
  639. if (shouldReverseRight) {
  640. position = this._reversePos(position);
  641. }
  642. break;
  643. case 'rightTop':
  644. if (shouldReverseRight) {
  645. position = this._reversePos(position);
  646. }
  647. if (shouldReverseTopSide && heightIsBigger) {
  648. position = this._reversePos(position, true);
  649. }
  650. break;
  651. case 'rightBottom':
  652. if (shouldReverseRight) {
  653. position = this._reversePos(position);
  654. }
  655. if (shouldReverseBottomSide && heightIsBigger) {
  656. position = this._reversePos(position, true);
  657. }
  658. break;
  659. case 'leftTopOver':
  660. if (shouldReverseTopOver) {
  661. position = this._reversePos(position, true);
  662. }
  663. if (shouldReverseLeftOver) {
  664. position = this._reversePos(position);
  665. }
  666. break;
  667. case 'leftBottomOver':
  668. if (shouldReverseBottomOver) {
  669. position = this._reversePos(position, true);
  670. }
  671. if (shouldReverseLeftOver) {
  672. position = this._reversePos(position);
  673. }
  674. break;
  675. case 'rightTopOver':
  676. if (shouldReverseTopOver) {
  677. position = this._reversePos(position, true);
  678. }
  679. if (shouldReverseRightOver) {
  680. position = this._reversePos(position);
  681. }
  682. break;
  683. case 'rightBottomOver':
  684. if (shouldReverseBottomOver) {
  685. position = this._reversePos(position, true);
  686. }
  687. if (shouldReverseRightOver) {
  688. position = this._reversePos(position);
  689. }
  690. break;
  691. default:
  692. break;
  693. }
  694. }
  695. return position;
  696. }
  697. delayHide = () => {
  698. const mouseLeaveDelay = this.getProp('mouseLeaveDelay');
  699. this.clearDelayTimer();
  700. if (mouseLeaveDelay > 0) {
  701. this._timer = setTimeout(() => {
  702. // console.log('delayHide for ', mouseLeaveDelay, ' ms, ', ...args);
  703. this.hide();
  704. this.clearDelayTimer();
  705. }, mouseLeaveDelay);
  706. } else {
  707. this.hide();
  708. }
  709. };
  710. hide = () => {
  711. this.clearDelayTimer();
  712. this._togglePortalVisible(false);
  713. this._adapter.off('portalInserted');
  714. this._adapter.off('positionUpdated');
  715. if (!this._adapter.canMotion()) {
  716. this._adapter.removePortal();
  717. // When the portal is removed, the global click outside event binding is also removed
  718. this._adapter.unregisterClickOutsideHandler();
  719. this._unBindScrollEvent();
  720. this._unBindResizeEvent();
  721. }
  722. };
  723. _bindScrollEvent() {
  724. this._adapter.registerScrollHandler(() => this.calcPosition());
  725. // Capture scroll events on the window to determine whether the current scrolling area (e.target) will affect the positioning of the pop-up layer relative to the viewport when scrolling
  726. // (By determining whether the e.target contains the triggerDom of the current tooltip) If so, the pop-up layer will also be affected and needs to be repositioned
  727. }
  728. _unBindScrollEvent() {
  729. this._adapter.unregisterScrollHandler();
  730. }
  731. _initContainerPosition() {
  732. this._adapter.updateContainerPosition();
  733. }
  734. handleContainerKeydown = (event: any) => {
  735. const { guardFocus, closeOnEsc } = this.getProps();
  736. switch (event && event.key) {
  737. case "Escape":
  738. closeOnEsc && this._handleEscKeyDown(event);
  739. break;
  740. case "Tab":
  741. if (guardFocus) {
  742. const container = this._adapter.getContainer();
  743. const focusableElements = this._adapter.getFocusableElements(container);
  744. const focusableNum = focusableElements.length;
  745. if (focusableNum) {
  746. // Shift + Tab will move focus backward
  747. if (event.shiftKey) {
  748. this._handleContainerShiftTabKeyDown(focusableElements, event);
  749. } else {
  750. this._handleContainerTabKeyDown(focusableElements, event);
  751. }
  752. }
  753. }
  754. break;
  755. default:
  756. break;
  757. }
  758. }
  759. _handleTriggerKeydown(event: any) {
  760. const { closeOnEsc } = this.getProps();
  761. const container = this._adapter.getContainer();
  762. const focusableElements = this._adapter.getFocusableElements(container);
  763. const focusableNum = focusableElements.length;
  764. switch (event && event.key) {
  765. case "Escape":
  766. handlePrevent(event);
  767. closeOnEsc && this._handleEscKeyDown(event);
  768. break;
  769. case "ArrowUp":
  770. focusableNum && this._handleTriggerArrowUpKeydown(focusableElements, event);
  771. break;
  772. case "ArrowDown":
  773. focusableNum && this._handleTriggerArrowDownKeydown(focusableElements, event);
  774. break;
  775. default:
  776. break;
  777. }
  778. }
  779. /**
  780. * focus trigger
  781. *
  782. * when trigger is 'focus' or 'hover', onFocus is bind to show popup
  783. * if we focus trigger, popup will show again
  784. *
  785. * 如果 trigger 是 focus 或者 hover,则它绑定了 onFocus,这里我们如果重新 focus 的话,popup 会再次打开
  786. * 因此 returnFocusOnClose 只支持 click trigger
  787. */
  788. _focusTrigger() {
  789. const { trigger, returnFocusOnClose, preventScroll } = this.getProps();
  790. if (returnFocusOnClose && trigger !== 'custom') {
  791. const triggerNode = this._adapter.getTriggerNode();
  792. if (triggerNode && 'focus' in triggerNode) {
  793. triggerNode.focus({ preventScroll });
  794. }
  795. }
  796. }
  797. _handleEscKeyDown(event: any) {
  798. const { trigger } = this.getProps();
  799. if (trigger !== 'custom') {
  800. // Move the focus into the trigger first and then close the pop-up layer
  801. // to avoid the problem of opening the pop-up layer again when the focus returns to the trigger in the case of hover and focus
  802. this._focusTrigger();
  803. this.hide();
  804. }
  805. this._adapter.notifyEscKeydown(event);
  806. }
  807. _handleContainerTabKeyDown(focusableElements: any[], event: any) {
  808. const { preventScroll } = this.getProps();
  809. const activeElement = this._adapter.getActiveElement();
  810. const isLastCurrentFocus = focusableElements[focusableElements.length - 1] === activeElement;
  811. if (isLastCurrentFocus) {
  812. focusableElements[0].focus({ preventScroll });
  813. event.preventDefault(); // prevent browser default tab move behavior
  814. }
  815. }
  816. _handleContainerShiftTabKeyDown(focusableElements: any[], event: any) {
  817. const { preventScroll } = this.getProps();
  818. const activeElement = this._adapter.getActiveElement();
  819. const isFirstCurrentFocus = focusableElements[0] === activeElement;
  820. if (isFirstCurrentFocus) {
  821. focusableElements[focusableElements.length - 1].focus({ preventScroll });
  822. event.preventDefault(); // prevent browser default tab move behavior
  823. }
  824. }
  825. _handleTriggerArrowDownKeydown(focusableElements: any[], event: any) {
  826. const { preventScroll } = this.getProps();
  827. focusableElements[0].focus({ preventScroll });
  828. event.preventDefault(); // prevent browser default scroll behavior
  829. }
  830. _handleTriggerArrowUpKeydown(focusableElements: any[], event: any) {
  831. const { preventScroll } = this.getProps();
  832. focusableElements[focusableElements.length - 1].focus({ preventScroll });
  833. event.preventDefault(); // prevent browser default scroll behavior
  834. }
  835. }