/* eslint-disable no-param-reassign */ /* eslint-disable prefer-destructuring, max-lines-per-function, one-var, max-len, @typescript-eslint/restrict-plus-operands */ /* argus-disable unPkgSensitiveInfo */ import { get, isEmpty } from 'lodash'; import { DOMRectLikeType } from '../utils/dom'; import BaseFoundation, { DefaultAdapter } from '../base/foundation'; import { ArrayElement } from '../utils/type'; import { strings } from './constants'; import { handlePrevent } from '../utils/a11y'; const REGS = { TOP: /top/i, RIGHT: /right/i, BOTTOM: /bottom/i, LEFT: /left/i, }; const defaultRect = { left: 0, top: 0, height: 0, width: 0, scrollLeft: 0, scrollTop: 0, }; export interface TooltipAdapter

, S = Record> extends DefaultAdapter { registerPortalEvent(portalEventSet: any): void; registerResizeHandler(onResize: () => void): void; unregisterResizeHandler(onResize?: () => void): void; on(arg0: string, arg1: () => void): void; notifyVisibleChange(isVisible: any): void; getPopupContainerRect(): PopupContainerDOMRect; containerIsBody(): boolean; off(arg0: string): void; canMotion(): boolean; registerScrollHandler(arg: () => Record): void; unregisterScrollHandler(): void; insertPortal(...args: any[]): void; removePortal(...args: any[]): void; setDisplayNone: (displayNone: boolean, cb?: () => void) => void; getEventName(): { mouseEnter: string; mouseLeave: string; mouseOut: string; mouseOver: string; click: string; focus: string; blur: string; keydown: string }; registerTriggerEvent(...args: any[]): void; getTriggerBounding(...args: any[]): DOMRect; getWrapperBounding(...args: any[]): DOMRect; setPosition(...args: any[]): void; togglePortalVisible(...args: any[]): void; registerClickOutsideHandler(...args: any[]): void; unregisterClickOutsideHandler(...args: any[]): void; containerIsRelative(): boolean; containerIsRelativeOrAbsolute(): boolean; getDocumentElementBounding(): DOMRect; updateContainerPosition(): void; updatePlacementAttr(placement: Position): void; getContainerPosition(): string; getFocusableElements(node: any): any[]; getActiveElement(): any; getContainer(): any; setInitialFocus(): void; notifyEscKeydown(event: any): void; getTriggerNode(): any; setId(): void } export type Position = ArrayElement; export interface PopupContainerDOMRect extends DOMRectLikeType { scrollLeft?: number; scrollTop?: number } export default class Tooltip

, S = Record> extends BaseFoundation, P, S> { _timer: ReturnType; _mounted: boolean; constructor(adapter: TooltipAdapter) { super({ ...adapter }); this._timer = null; } init() { const { wrapperId } = this.getProps(); this._mounted = true; this._bindEvent(); this._shouldShow(); this._initContainerPosition(); if (!wrapperId) { this._adapter.setId(); } } destroy() { this._mounted = false; this.unBindEvent(); } _bindEvent() { const trigger = this.getProp('trigger'); // get trigger type const { triggerEventSet, portalEventSet } = this._generateEvent(trigger); this._bindTriggerEvent(triggerEventSet); this._bindPortalEvent(portalEventSet); this._bindResizeEvent(); } unBindEvent() { this._adapter.unregisterClickOutsideHandler(); this.unBindResizeEvent(); this.unBindScrollEvent(); } _bindTriggerEvent(triggerEventSet: Record) { this._adapter.registerTriggerEvent(triggerEventSet); } _bindPortalEvent(portalEventSet: Record) { this._adapter.registerPortalEvent(portalEventSet); } _bindResizeEvent() { this._adapter.registerResizeHandler(this.onResize); } unBindResizeEvent() { this._adapter.unregisterResizeHandler(this.onResize); } removePortal = () => { this._adapter.removePortal(); } setDisplayNone: (displayNone: boolean, cb?: () => void) => void = (displayNone, cb) => { this._adapter.setDisplayNone(displayNone, cb); } _adjustPos(position = '', isVertical = false, adjustType = 'reverse', concatPos?: any) { switch (adjustType) { case 'reverse': return this._reversePos(position, isVertical); case 'expand': // only happens when position is top/bottom/left/right return this._expandPos(position, concatPos); case 'reduce': // only happens when position other than top/bottom/left/right return this._reducePos(position); default: return this._reversePos(position, isVertical); } } _reversePos(position = '', isVertical = false) { if (isVertical) { if (REGS.TOP.test(position)) { return position.replace('top', 'bottom').replace('Top', 'Bottom'); } else if (REGS.BOTTOM.test(position)) { return position.replace('bottom', 'top').replace('Bottom', 'Top'); } } else if (REGS.LEFT.test(position)) { return position.replace('left', 'right').replace('Left', 'Right'); } else if (REGS.RIGHT.test(position)) { return position.replace('right', 'left').replace('Right', 'Left'); } return position; } _expandPos(position = '', concatPos: string) { return position.concat(concatPos); } _reducePos(position = '') { // if cur position consists of two directions, remove the last position const found = ['Top', 'Bottom', 'Left', 'Right'].find(pos => position.endsWith(pos)); return found ? position.replace(found, ''): position; } clearDelayTimer() { if (this._timer) { clearTimeout(this._timer); this._timer = null; } } _generateEvent(types: ArrayElement) { const eventNames = this._adapter.getEventName(); const triggerEventSet = { // bind esc keydown on trigger for a11y [eventNames.keydown]: (event) => { this._handleTriggerKeydown(event); }, }; let portalEventSet = {}; switch (types) { case 'focus': triggerEventSet[eventNames.focus] = () => { this.delayShow(); }; triggerEventSet[eventNames.blur] = () => { this.delayHide(); }; portalEventSet = triggerEventSet; break; case 'click': triggerEventSet[eventNames.click] = () => { // this.delayShow(); this.show(); }; portalEventSet = {}; // Click outside needs special treatment, can not be directly tied to the trigger Element, need to be bound to the document break; case 'hover': triggerEventSet[eventNames.mouseEnter] = () => { // console.log(e); this.setCache('isClickToHide', false); this.delayShow(); // this.show('trigger'); }; triggerEventSet[eventNames.mouseLeave] = () => { // console.log(e); this.delayHide(); // this.hide('trigger'); }; // bind focus to hover trigger for a11y triggerEventSet[eventNames.focus] = () => { const { disableFocusListener } = this.getProps(); !disableFocusListener && this.delayShow(); }; triggerEventSet[eventNames.blur] = () => { const { disableFocusListener } = this.getProps(); !disableFocusListener && this.delayHide(); }; portalEventSet = { ...triggerEventSet }; if (this.getProp('clickToHide')) { portalEventSet[eventNames.click] = () => { this.setCache('isClickToHide', true); this.hide(); }; portalEventSet[eventNames.mouseEnter] = () => { if (this.getCache('isClickToHide')) { return; } this.delayShow(); }; } break; case 'custom': // when trigger type is 'custom', no need to bind eventHandler // show/hide completely depend on props.visible which change by user break; default: break; } return { triggerEventSet, portalEventSet }; } onResize = () => { // this.log('resize'); // rePosition when window resize this.calcPosition(); }; _shouldShow() { const visible = this.getProp('visible'); if (visible) { this.show(); } else { // this.hide(); } } delayShow = () => { const mouseEnterDelay: number = this.getProp('mouseEnterDelay'); this.clearDelayTimer(); if (mouseEnterDelay > 0) { this._timer = setTimeout(() => { this.show(); this.clearDelayTimer(); }, mouseEnterDelay); } else { this.show(); } }; show = () => { const content = this.getProp('content'); const trigger = this.getProp('trigger'); const clickTriggerToHide = this.getProp('clickTriggerToHide'); const { visible, displayNone } = this.getStates(); if (displayNone) { this.setDisplayNone(false); } if (visible) { return ; } this.clearDelayTimer(); /** * If you emit an event in setState callback, you need to place the event listener function before setState to execute. * This is to avoid event registration being executed later than setState callback when setState is executed in setTimeout. * internal-issues:1402#note_38969412 */ this._adapter.on('portalInserted', () => { this.calcPosition(); }); this._adapter.on('positionUpdated', () => { this._togglePortalVisible(true); }); this._adapter.insertPortal(content, { left: -9999, top: -9999 }); // offscreen rendering if (trigger === 'custom') { // eslint-disable-next-line this._adapter.registerClickOutsideHandler(() => {}); } /** * trigger类型是click时,仅当portal被插入显示后,才绑定clickOutsideHandler * 因为handler需要绑定在document上。如果在constructor阶段绑定 * 当一个页面中有多个容器实例时,一次click会触发多个容器的handler * * When the trigger type is click, clickOutsideHandler is bound only after the portal is inserted and displayed * Because the handler needs to be bound to the document. If you bind during the constructor phase * When there are multiple container instances in a page, one click triggers the handler of multiple containers */ if (trigger === 'click' || clickTriggerToHide) { this._adapter.registerClickOutsideHandler(this.hide); } this._bindScrollEvent(); this._bindResizeEvent(); }; _togglePortalVisible(isVisible: boolean) { const nowVisible = this.getState('visible'); if (nowVisible !== isVisible) { this._adapter.togglePortalVisible(isVisible, () => { if (isVisible) { this._adapter.setInitialFocus(); } this._adapter.notifyVisibleChange(isVisible); }); } } _roundPixel(pixel: number) { if (typeof pixel === 'number') { return Math.round(pixel); } return pixel; } calcTransformOrigin(position: Position, triggerRect: DOMRect, translateX: number, translateY: number) { // eslint-disable-next-line if (position && triggerRect && translateX != null && translateY != null) { if (this.getProp('transformFromCenter')) { if (['topLeft', 'bottomLeft'].includes(position)) { return `${this._roundPixel(triggerRect.width / 2)}px ${-translateY * 100}%`; } if (['topRight', 'bottomRight'].includes(position)) { return `calc(100% - ${this._roundPixel(triggerRect.width / 2)}px) ${-translateY * 100}%`; } if (['leftTop', 'rightTop'].includes(position)) { return `${-translateX * 100}% ${this._roundPixel(triggerRect.height / 2)}px`; } if (['leftBottom', 'rightBottom'].includes(position)) { return `${-translateX * 100}% calc(100% - ${this._roundPixel(triggerRect.height / 2)}px)`; } } return `${-translateX * 100}% ${-translateY * 100}%`; } return null; } calcPosStyle(props: {triggerRect: DOMRect; wrapperRect: DOMRect; containerRect: PopupContainerDOMRect; position?: Position; spacing?: number; isOverFlow?: [boolean, boolean]}) { const { spacing, isOverFlow } = props; const { innerWidth } = window; const triggerRect = (isEmpty(props.triggerRect) ? props.triggerRect : this._adapter.getTriggerBounding()) || { ...defaultRect as any }; const containerRect = (isEmpty(props.containerRect) ? props.containerRect : this._adapter.getPopupContainerRect()) || { ...defaultRect, }; const wrapperRect = (isEmpty(props.wrapperRect) ? props.wrapperRect : this._adapter.getWrapperBounding()) || { ...defaultRect as any }; // eslint-disable-next-line const position = props.position != null ? props.position : this.getProp('position'); // eslint-disable-next-line const SPACING = spacing != null ? spacing : this.getProp('spacing'); const { arrowPointAtCenter, showArrow, arrowBounding } = this.getProps(); const pointAtCenter = showArrow && arrowPointAtCenter; const horizontalArrowWidth = get(arrowBounding, 'width', 24); const verticalArrowHeight = get(arrowBounding, 'width', 24); const arrowOffsetY = get(arrowBounding, 'offsetY', 0); const positionOffsetX = 6; const positionOffsetY = 6; // You must use left/top when rendering, using right/bottom does not render the element position correctly // Use left/top + translate to achieve tooltip positioning perfectly without knowing the size of the tooltip expansion layer let left; let top; let translateX = 0; // Container x-direction translation distance let translateY = 0; // Container y-direction translation distance const middleX = triggerRect.left + triggerRect.width / 2; const middleY = triggerRect.top + triggerRect.height / 2; const offsetXWithArrow = positionOffsetX + horizontalArrowWidth / 2; const offsetYWithArrow = positionOffsetY + verticalArrowHeight / 2; const heightDifference = wrapperRect.height - containerRect.height; const widthDifference = wrapperRect.width - containerRect.width; const offsetHeight = heightDifference > 0 ? heightDifference : 0; const offsetWidth = widthDifference > 0 ? widthDifference : 0; const isHeightOverFlow = isOverFlow && isOverFlow[0]; const isWidthOverFlow = isOverFlow && isOverFlow[1]; const isTriggerNearLeft = middleX - containerRect.left < containerRect.right - middleX; const isTriggerNearTop = middleY - containerRect.top < containerRect.bottom - middleY; const isWrapperWidthOverflow = wrapperRect.width > innerWidth; switch (position) { case 'top': // left = middleX; // top = triggerRect.top - SPACING; left = isWidthOverFlow ? (isTriggerNearLeft ? containerRect.left + wrapperRect.width / 2 : containerRect.right - wrapperRect.width / 2 + offsetWidth): middleX; top = isHeightOverFlow ? containerRect.bottom + offsetHeight : triggerRect.top - SPACING; translateX = -0.5; translateY = -1; break; case 'topLeft': // left = pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left; // top = triggerRect.top - SPACING; left = isWidthOverFlow ? (isWrapperWidthOverflow ? containerRect.left : containerRect.right - wrapperRect.width ) : (pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left); top = isHeightOverFlow ? containerRect.bottom + offsetHeight : triggerRect.top - SPACING; translateY = -1; break; case 'topRight': // left = pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right; // top = triggerRect.top - SPACING; left = isWidthOverFlow ? containerRect.right + offsetWidth : (pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right); top = isHeightOverFlow ? containerRect.bottom + offsetHeight : triggerRect.top - SPACING; translateY = -1; translateX = -1; break; case 'left': // left = triggerRect.left - SPACING; // top = middleY; // left = isWidthOverFlow? containerRect.right - SPACING : triggerRect.left - SPACING; left = isWidthOverFlow ? containerRect.right + offsetWidth - SPACING + offsetXWithArrow : triggerRect.left - SPACING; top = isHeightOverFlow ? (isTriggerNearTop ? containerRect.top + wrapperRect.height / 2 : containerRect.bottom - wrapperRect.height / 2 + offsetHeight): middleY; translateX = -1; translateY = -0.5; break; case 'leftTop': // left = triggerRect.left - SPACING; // top = pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top; left = isWidthOverFlow ? containerRect.right + offsetWidth - SPACING + offsetXWithArrow : triggerRect.left - SPACING; top = isHeightOverFlow ? containerRect.top : (pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top); translateX = -1; break; case 'leftBottom': // left = triggerRect.left - SPACING; // top = pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom; left = isWidthOverFlow ? containerRect.right + offsetWidth - SPACING + offsetXWithArrow: triggerRect.left - SPACING; top = isHeightOverFlow ? containerRect.bottom + offsetHeight: (pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom); translateX = -1; translateY = -1; break; case 'bottom': // left = middleX; // top = triggerRect.top + triggerRect.height + SPACING; left = isWidthOverFlow ? (isTriggerNearLeft ? containerRect.left + wrapperRect.width / 2 : containerRect.right - wrapperRect.width / 2 + offsetWidth): middleX; top = isHeightOverFlow ? containerRect.top + offsetYWithArrow - SPACING: triggerRect.top + triggerRect.height + SPACING; translateX = -0.5; break; case 'bottomLeft': // left = pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left; // top = triggerRect.bottom + SPACING; left = isWidthOverFlow ? (isWrapperWidthOverflow ? containerRect.left : containerRect.right - wrapperRect.width ) : (pointAtCenter ? middleX - offsetXWithArrow : triggerRect.left); top = isHeightOverFlow ? containerRect.top + offsetYWithArrow - SPACING : triggerRect.top + triggerRect.height + SPACING; break; case 'bottomRight': // left = pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right; // top = triggerRect.bottom + SPACING; left = isWidthOverFlow ? containerRect.right + offsetWidth : (pointAtCenter ? middleX + offsetXWithArrow : triggerRect.right); top = isHeightOverFlow ? containerRect.top + offsetYWithArrow - SPACING : triggerRect.top + triggerRect.height + SPACING; translateX = -1; break; case 'right': // left = triggerRect.right + SPACING; // top = middleY; left = isWidthOverFlow ? containerRect.left - SPACING + offsetXWithArrow : triggerRect.right + SPACING; top = isHeightOverFlow ? (isTriggerNearTop ? containerRect.top + wrapperRect.height / 2 : containerRect.bottom - wrapperRect.height / 2 + offsetHeight) : middleY; translateY = -0.5; break; case 'rightTop': // left = triggerRect.right + SPACING; // top = pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top; left = isWidthOverFlow ? containerRect.left - SPACING + offsetXWithArrow : triggerRect.right + SPACING; top = isHeightOverFlow ? containerRect.top : (pointAtCenter ? middleY - offsetYWithArrow : triggerRect.top); break; case 'rightBottom': // left = triggerRect.right + SPACING; // top = pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom; left = isWidthOverFlow ? containerRect.left - SPACING + offsetXWithArrow : triggerRect.right + SPACING; top = isHeightOverFlow ? containerRect.bottom + offsetHeight : (pointAtCenter ? middleY + offsetYWithArrow : triggerRect.bottom); translateY = -1; break; case 'leftTopOver': left = triggerRect.left - SPACING; top = triggerRect.top - SPACING; break; case 'rightTopOver': left = triggerRect.right + SPACING; top = triggerRect.top - SPACING; translateX = -1; break; case 'leftBottomOver': left = triggerRect.left - SPACING; top = triggerRect.bottom + SPACING; translateY = -1; break; case 'rightBottomOver': left = triggerRect.right + SPACING; top = triggerRect.bottom + SPACING; translateX = -1; translateY = -1; break; default: break; } const transformOrigin = this.calcTransformOrigin(position, triggerRect, translateX, translateY); // Transform origin const _containerIsBody = this._adapter.containerIsBody(); // Calculate container positioning relative to window left = left - containerRect.left; top = top - containerRect.top; /** * container为body时,如果position不为relative或absolute,这时trigger计算出的top/left会根据html定位(initial containing block) * 此时如果body有margin,则计算出的位置相对于body会有问题 fix issue #1368 * * When container is body, if position is not relative or absolute, then the top/left calculated by trigger will be positioned according to html * At this time, if the body has a margin, the calculated position will have a problem relative to the body fix issue #1368 */ if (_containerIsBody && !this._adapter.containerIsRelativeOrAbsolute()) { const documentEleRect = this._adapter.getDocumentElementBounding(); // Represents the left of the body relative to html left += containerRect.left - documentEleRect.left; // Represents the top of the body relative to html top += containerRect.top - documentEleRect.top; } // ContainerRect.scrollLeft to solve the inner scrolling of the container left = _containerIsBody ? left : left + containerRect.scrollLeft; top = _containerIsBody ? top : top + containerRect.scrollTop; const triggerHeight = triggerRect.height; if ( this.getProp('showArrow') && !arrowPointAtCenter && triggerHeight <= (verticalArrowHeight / 2 + arrowOffsetY) * 2 ) { const offsetY = triggerHeight / 2 - (arrowOffsetY + verticalArrowHeight / 2); if ((position.includes('Top') || position.includes('Bottom')) && !position.includes('Over')) { top = position.includes('Top') ? top + offsetY : top - offsetY; } } // The left/top value here must be rounded, otherwise it will cause the small triangle to shake const style: Record = { left: this._roundPixel(left), top: this._roundPixel(top), }; let transform = ''; // eslint-disable-next-line if (translateX != null) { transform += `translateX(${translateX * 100}%) `; Object.defineProperty(style, 'translateX', { enumerable: false, value: translateX, }); } // eslint-disable-next-line if (translateY != null) { transform += `translateY(${translateY * 100}%) `; Object.defineProperty(style, 'translateY', { enumerable: false, value: translateY, }); } // eslint-disable-next-line if (transformOrigin != null) { style.transformOrigin = transformOrigin; } if (transform) { style.transform = transform; } return style; } /** * 耦合的东西比较多,稍微罗列一下: * * - 根据 trigger 和 wrapper 的 boundingClient 计算当前的 left、top、transform-origin * - 根据当前的 position 和 wrapper 的 boundingClient 决定是否需要自动调整位置 * - 根据当前的 position、trigger 的 boundingClient 以及 motion.handleStyle 调整当前的 style * * There are many coupling things, a little list: * * - calculate the current left, top, and transfer-origin according to the boundingClient of trigger and wrapper * - decide whether to automatically adjust the position according to the current position and the boundingClient of wrapper * - adjust the current style according to the current position, the boundingClient of trigger and motion.handle Style */ calcPosition = (triggerRect?: DOMRect, wrapperRect?: DOMRect, containerRect?: PopupContainerDOMRect, shouldUpdatePos = true) => { triggerRect = (isEmpty(triggerRect) ? this._adapter.getTriggerBounding() : triggerRect) || { ...defaultRect as any }; containerRect = (isEmpty(containerRect) ? this._adapter.getPopupContainerRect() : containerRect) || { ...defaultRect, }; wrapperRect = (isEmpty(wrapperRect) ? this._adapter.getWrapperBounding() : wrapperRect) || { ...defaultRect as any }; // console.log('containerRect: ', containerRect, 'triggerRect: ', triggerRect, 'wrapperRect: ', wrapperRect); let style = this.calcPosStyle({ triggerRect, wrapperRect, containerRect }); let position = this.getProp('position'); if (this.getProp('autoAdjustOverflow')) { // console.log('style: ', style, '\ntriggerRect: ', triggerRect, '\nwrapperRect: ', wrapperRect); const { position: adjustedPos, isHeightOverFlow, isWidthOverFlow } = this.adjustPosIfNeed(position, style, triggerRect, wrapperRect, containerRect); if (position !== adjustedPos || isHeightOverFlow || isWidthOverFlow) { position = adjustedPos; style = this.calcPosStyle({ triggerRect, wrapperRect, containerRect, position, spacing: null, isOverFlow: [ isHeightOverFlow, isWidthOverFlow ] }); } } if (shouldUpdatePos && this._mounted) { // this._adapter.updatePlacementAttr(style.position); this._adapter.setPosition({ ...style, position }); } return style; }; isLR(position = '') { return position.includes('left') || position.includes('right'); } isTB(position = '') { return position.includes('top') || position.includes('bottom'); } isReverse(rowSpace: number, reverseSpace: number, size: number) { // 原空间不足,反向空间足够 // Insufficient original space, enough reverse space return rowSpace < size && reverseSpace > size; } isOverFlow(rowSpace: number, reverseSpace: number, size: number) { // 原空间且反向空间都不足 // The original space and the reverse space are not enough return rowSpace < size && reverseSpace < size; } isHalfOverFlow(posSpace: number, negSpace: number, size: number) { // 正半空间或者负半空间不足,即表示有遮挡,需要偏移 // Insufficient positive half space or negative half space means that there is occlusion and needs to be offset return posSpace < size || negSpace < size; } isHalfAllEnough(posSpace: number, negSpace: number, size: number) { // 正半空间和负半空间都足够,即表示可以从 topLeft/topRight 变成 top // Both positive and negative half-spaces are sufficient, which means you can change from topLeft/topRight to top return posSpace >= size || negSpace >= size; } getReverse(viewOverFlow: boolean, containerOverFlow: boolean, shouldReverseView: boolean, shouldReverseContainer: boolean) { /** * 基于视口和容器一起判断,以下几种情况允许从原方向转到反方向,以判断是否应该由top->bottom为例子 * * 1. 视口上下空间不足 且 容器上空间❌下空间✅ * 2. 视口上空间❌下空间✅ 且 容器上下空间不足 * 3. 视口上空间❌下空间✅ 且 容器上空间❌下空间✅ * * Based on the judgment of the viewport and the container, the following situations are allowed to turn from the original direction to the opposite direction * to judge whether it should be top->bottom as an example * 1. There is insufficient space above and below the viewport and the space above the container ❌ the space below ✅ * 2. The space above the viewport ❌ the space below ✅ and the space above and below the container is insufficient * 3. Viewport upper space ❌ lower space✅ and container upper space ❌ lower space✅ */ return (viewOverFlow && shouldReverseContainer) || (shouldReverseView && containerOverFlow) || (shouldReverseView && shouldReverseContainer); } // place the dom correctly adjustPosIfNeed(position: Position | string, style: Record, triggerRect: DOMRect, wrapperRect: DOMRect, containerRect: PopupContainerDOMRect) { const { innerWidth, innerHeight } = window; const { spacing, margin } = this.getProps(); const marginLeft = typeof margin === 'number' ? margin : margin.marginLeft; const marginTop = typeof margin === 'number' ? margin : margin.marginTop; const marginRight = typeof margin === 'number' ? margin : margin.marginRight; const marginBottom = typeof margin === 'number' ? margin : margin.marginBottom; let isHeightOverFlow = false; let isWidthOverFlow = false; if (wrapperRect.width > 0 && wrapperRect.height > 0) { // let clientLeft = left + translateX * wrapperRect.width - containerRect.scrollLeft; // let clientTop = top + translateY * wrapperRect.height - containerRect.scrollTop; // if (this._adapter.containerIsBody() || this._adapter.containerIsRelative()) { // clientLeft += containerRect.left; // clientTop += containerRect.top; // } // const clientRight = clientLeft + wrapperRect.width; // const clientBottom = clientTop + wrapperRect.height; // The relative position of the elements on the screen // https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/tooltip-pic.svg const clientLeft = triggerRect.left; const clientRight = triggerRect.right; const clientTop = triggerRect.top; const clientBottom = triggerRect.bottom; const restClientLeft = innerWidth - clientLeft; const restClientTop = innerHeight - clientTop; const restClientRight = innerWidth - clientRight; const restClientBottom = innerHeight - clientBottom; const widthIsBigger = wrapperRect.width > triggerRect.width; const heightIsBigger = wrapperRect.height > triggerRect.height; // The wrapperR ect.top|bottom equivalent cannot be directly used here for comparison, which is easy to cause jitter // 基于视口的微调判断 // Fine-tuning judgment based on viewport const shouldViewReverseTop = clientTop - marginTop < wrapperRect.height + spacing && restClientBottom - marginBottom > wrapperRect.height + spacing; const shouldViewReverseLeft = clientLeft - marginLeft < wrapperRect.width + spacing && restClientRight - marginRight > wrapperRect.width + spacing; const shouldViewReverseBottom = restClientBottom - marginBottom < wrapperRect.height + spacing && clientTop - marginTop > wrapperRect.height + spacing; const shouldViewReverseRight = restClientRight - marginRight < wrapperRect.width + spacing && clientLeft - marginLeft > wrapperRect.width + spacing; const shouldViewReverseTopOver = restClientTop - marginBottom< wrapperRect.height + spacing && clientBottom - marginTop> wrapperRect.height + spacing; const shouldViewReverseBottomOver = clientBottom - marginTop < wrapperRect.height + spacing && restClientTop - marginBottom > wrapperRect.height + spacing; const shouldViewReverseTopSide = restClientTop < wrapperRect.height && clientBottom > wrapperRect.height; const shouldViewReverseBottomSide = clientBottom < wrapperRect.height && restClientTop > wrapperRect.height; const shouldViewReverseLeftSide = restClientLeft < wrapperRect.width && clientRight > wrapperRect.width; const shouldViewReverseRightSide = clientRight < wrapperRect.width && restClientLeft > wrapperRect.width; const shouldReverseTopOver = restClientTop < wrapperRect.height + spacing && clientBottom > wrapperRect.height + spacing; const shouldReverseBottomOver = clientBottom < wrapperRect.height + spacing && restClientTop > wrapperRect.height + spacing; const shouldReverseLeftOver = restClientLeft < wrapperRect.width && clientRight > wrapperRect.width; const shouldReverseRightOver = clientRight < wrapperRect.width && restClientLeft > wrapperRect.width; // 基于容器的微调判断 // Fine-tuning judgment based on container const clientTopInContainer = clientTop - containerRect.top; const clientLeftInContainer = clientLeft - containerRect.left; const clientBottomInContainer = clientTopInContainer + triggerRect.height; const clientRightInContainer = clientLeftInContainer + triggerRect.width; const restClientBottomInContainer = containerRect.bottom - clientBottom; const restClientRightInContainer = containerRect.right - clientRight; const restClientTopInContainer = restClientBottomInContainer + triggerRect.height; const restClientLeftInContainer = restClientRightInContainer + triggerRect.width; // 当原空间不足,反向空间足够时,可以反向。 // When the original space is insufficient and the reverse space is sufficient, the reverse can be performed. const shouldContainerReverseTop = this.isReverse(clientTopInContainer - marginTop, restClientBottomInContainer - marginBottom, wrapperRect.height + spacing); const shouldContainerReverseLeft = this.isReverse(clientLeftInContainer - marginLeft, restClientRightInContainer - marginRight, wrapperRect.width + spacing); const shouldContainerReverseBottom = this.isReverse(restClientBottomInContainer - marginBottom, clientTopInContainer - marginTop, wrapperRect.height + spacing); const shouldContainerReverseRight = this.isReverse(restClientRightInContainer - marginRight, clientLeftInContainer - marginLeft, wrapperRect.width + spacing); const shouldContainerReverseTopOver = this.isReverse(restClientTopInContainer - marginBottom, clientBottomInContainer - marginTop, wrapperRect.height + spacing); const shouldContainerReverseBottomOver = this.isReverse(clientBottomInContainer - marginTop, restClientTopInContainer - marginBottom, wrapperRect.height + spacing); const shouldContainerReverseTopSide = this.isReverse(restClientTopInContainer, clientBottomInContainer, wrapperRect.height); const shouldContainerReverseBottomSide = this.isReverse(clientBottomInContainer, restClientTopInContainer, wrapperRect.height); const shouldContainerReverseLeftSide = this.isReverse(restClientLeftInContainer, clientRightInContainer, wrapperRect.width); const shouldContainerReverseRightSide = this.isReverse(clientRightInContainer, restClientLeftInContainer, wrapperRect.width); const halfHeight = triggerRect.height / 2; const halfWidth = triggerRect.width / 2; // 视口, 原空间与反向空间是否都不足判断 // Viewport, whether the original space and the reverse space are insufficient to judge const isViewYOverFlow = this.isOverFlow(clientTop - marginTop, restClientBottom - marginBottom, wrapperRect.height + spacing); const isViewXOverFlow = this.isOverFlow(clientLeft - marginLeft, restClientRight - marginRight, wrapperRect.width + spacing); const isViewYOverFlowSide = this.isOverFlow(clientBottom - marginTop, restClientTop - marginBottom, wrapperRect.height + spacing); const isViewXOverFlowSide = this.isOverFlow(clientRight - marginLeft, restClientLeft - marginRight, wrapperRect.width + spacing); const isViewYOverFlowSideHalf = this.isHalfOverFlow(clientBottom - halfHeight, restClientTop - halfHeight, wrapperRect.height / 2); const isViewXOverFlowSideHalf = this.isHalfOverFlow(clientRight - halfWidth, restClientLeft - halfWidth, wrapperRect.width / 2); const isViewYEnoughSideHalf = this.isHalfAllEnough(clientBottom - halfHeight, restClientTop - halfHeight, wrapperRect.height / 2); const isViewXEnoughSideHalf = this.isHalfAllEnough(clientRight - halfWidth, restClientLeft - halfWidth, wrapperRect.width / 2); // 容器, 原空间与反向空间是否都不足判断 // container, whether the original space and the reverse space are insufficient to judge const isContainerYOverFlow = this.isOverFlow(clientTopInContainer - marginTop, restClientBottomInContainer - marginBottom, wrapperRect.height + spacing); const isContainerXOverFlow = this.isOverFlow(clientLeftInContainer - marginLeft, restClientRightInContainer - marginRight, wrapperRect.width + spacing); const isContainerYOverFlowSide = this.isOverFlow(clientBottomInContainer - marginTop, restClientTopInContainer - marginBottom, wrapperRect.height + spacing); const isContainerXOverFlowSide = this.isOverFlow(clientRightInContainer - marginLeft, restClientLeftInContainer - marginRight, wrapperRect.width + spacing); const isContainerYOverFlowSideHalf = this.isHalfOverFlow(clientBottomInContainer - halfHeight, restClientTopInContainer - halfHeight, wrapperRect.height / 2); const isContainerXOverFlowSideHalf = this.isHalfOverFlow(clientRightInContainer - halfWidth, restClientLeftInContainer - halfWidth, wrapperRect.width / 2); const isContainerYEnoughSideHalf = this.isHalfAllEnough(clientBottomInContainer - halfHeight, restClientTopInContainer - halfHeight, wrapperRect.height / 2); const isContainerXEnoughSideHalf = this.isHalfAllEnough(clientRightInContainer - halfWidth, restClientLeftInContainer - halfWidth, wrapperRect.width / 2); // 综合 viewport + container 判断微调,即视口 + 容器都放置不行时才能考虑位置调整 // Comprehensive viewport + container judgment fine-tuning, that is, the position adjustment can only be considered when the viewport + container cannot be placed. const shouldReverseTop = this.getReverse(isViewYOverFlow, isContainerYOverFlow, shouldViewReverseTop, shouldContainerReverseTop); const shouldReverseLeft = this.getReverse(isViewXOverFlow, isContainerXOverFlow, shouldViewReverseLeft, shouldContainerReverseLeft); const shouldReverseBottom = this.getReverse(isViewYOverFlow, isContainerYOverFlow, shouldViewReverseBottom, shouldContainerReverseBottom); const shouldReverseRight = this.getReverse(isViewXOverFlow, isContainerXOverFlow, shouldViewReverseRight, shouldContainerReverseRight); // const shouldReverseTopOver = this.getReverse(isViewYOverFlowSide, isContainerYOverFlowSide, shouldViewReverseTopOver, shouldContainerReverseTopOver); // const shouldReverseBottomOver = this.getReverse(isViewYOverFlowSide, isContainerYOverFlowSide, shouldViewReverseBottomOver, shouldContainerReverseBottomOver); const shouldReverseTopSide = this.getReverse(isViewYOverFlowSide, isContainerYOverFlowSide, shouldViewReverseTopSide, shouldContainerReverseTopSide); const shouldReverseBottomSide = this.getReverse(isViewYOverFlowSide, isContainerYOverFlowSide, shouldViewReverseBottomSide, shouldContainerReverseBottomSide); const shouldReverseLeftSide = this.getReverse(isViewXOverFlowSide, isContainerXOverFlowSide, shouldViewReverseLeftSide, shouldContainerReverseLeftSide); const shouldReverseRightSide = this.getReverse(isViewXOverFlowSide, isContainerXOverFlowSide, shouldViewReverseRightSide, shouldContainerReverseRightSide); const isYOverFlowSideHalf = isViewYOverFlowSideHalf && isContainerYOverFlowSideHalf; const isXOverFlowSideHalf = isViewXOverFlowSideHalf && isContainerXOverFlowSideHalf; switch (position) { case 'top': if (shouldReverseTop) { position = this._adjustPos(position, true); } if (isXOverFlowSideHalf && (shouldReverseLeftSide || shouldReverseRightSide)) { position = this._adjustPos(position, true, 'expand', shouldReverseLeftSide ? 'Right' : 'Left'); } break; case 'topLeft': if (shouldReverseTop) { position = this._adjustPos(position, true); } if (shouldReverseLeftSide && widthIsBigger) { position = this._adjustPos(position); } if (isWidthOverFlow && (isViewXEnoughSideHalf || isContainerXEnoughSideHalf)) { position = this._adjustPos(position, true, 'reduce'); } break; case 'topRight': if (shouldReverseTop) { position = this._adjustPos(position, true); } if (shouldReverseRightSide && widthIsBigger) { position = this._adjustPos(position); } if (isWidthOverFlow && (isViewXEnoughSideHalf || isContainerXEnoughSideHalf)) { position = this._adjustPos(position, true, 'reduce'); } break; case 'left': if (shouldReverseLeft) { position = this._adjustPos(position); } if (isYOverFlowSideHalf && (shouldReverseTopSide || shouldReverseBottomSide)) { position = this._adjustPos(position, false, 'expand', shouldReverseTopSide ? 'Bottom' : 'Top'); } break; case 'leftTop': if (shouldReverseLeft) { position = this._adjustPos(position); } if (shouldReverseTopSide && heightIsBigger) { position = this._adjustPos(position, true); } if (isHeightOverFlow && (isViewYEnoughSideHalf || isContainerYEnoughSideHalf)) { position = this._adjustPos(position, false, 'reduce'); } break; case 'leftBottom': if (shouldReverseLeft) { position = this._adjustPos(position); } if (shouldReverseBottomSide && heightIsBigger) { position = this._adjustPos(position, true); } if (isHeightOverFlow && (isViewYEnoughSideHalf || isContainerYEnoughSideHalf)) { position = this._adjustPos(position, false, 'reduce'); } break; case 'bottom': if (shouldReverseBottom) { position = this._adjustPos(position, true); } if (isXOverFlowSideHalf && (shouldReverseLeftSide || shouldReverseRightSide)) { position = this._adjustPos(position, true, 'expand', shouldReverseLeftSide ? 'Right' : 'Left'); } break; case 'bottomLeft': if (shouldReverseBottom) { position = this._adjustPos(position, true); } if (shouldReverseLeftSide && widthIsBigger) { position = this._adjustPos(position); } if (isWidthOverFlow && (isViewXEnoughSideHalf || isContainerXEnoughSideHalf)) { position = this._adjustPos(position, true, 'reduce'); } break; case 'bottomRight': if (shouldReverseBottom) { position = this._adjustPos(position, true); } if (shouldReverseRightSide && widthIsBigger) { position = this._adjustPos(position); } if (isWidthOverFlow && (isViewXEnoughSideHalf || isContainerXEnoughSideHalf)) { position = this._adjustPos(position, true, 'reduce'); } break; case 'right': if (shouldReverseRight) { position = this._adjustPos(position); } if (isYOverFlowSideHalf && (shouldReverseTopSide || shouldReverseBottomSide)) { position = this._adjustPos(position, false, 'expand', shouldReverseTopSide ? 'Bottom' : 'Top'); } break; case 'rightTop': if (shouldReverseRight) { position = this._adjustPos(position); } if (shouldReverseTopSide && heightIsBigger) { position = this._adjustPos(position, true); } if (isHeightOverFlow && (isViewYEnoughSideHalf || isContainerYEnoughSideHalf)) { position = this._adjustPos(position, false, 'reduce'); } break; case 'rightBottom': if (shouldReverseRight) { position = this._adjustPos(position); } if (shouldReverseBottomSide && heightIsBigger) { position = this._adjustPos(position, true); } if (isHeightOverFlow && (isViewYEnoughSideHalf || isContainerYEnoughSideHalf)) { position = this._adjustPos(position, false, 'reduce'); } break; case 'leftTopOver': if (shouldReverseTopOver) { position = this._adjustPos(position, true); } if (shouldReverseLeftOver) { position = this._adjustPos(position); } break; case 'leftBottomOver': if (shouldReverseBottomOver) { position = this._adjustPos(position, true); } if (shouldReverseLeftOver) { position = this._adjustPos(position); } break; case 'rightTopOver': if (shouldReverseTopOver) { position = this._adjustPos(position, true); } if (shouldReverseRightOver) { position = this._adjustPos(position); } break; case 'rightBottomOver': if (shouldReverseBottomOver) { position = this._adjustPos(position, true); } if (shouldReverseRightOver) { position = this._adjustPos(position); } break; default: break; } // 判断溢出 Judgment overflow // 上下方向 top and bottom if (this.isTB(position)) { isHeightOverFlow = isViewYOverFlow && isContainerYOverFlow; // Related PR: https://github.com/DouyinFE/semi-design/pull/1297 // If clientRight or restClientRight less than 0, means that the left and right parts of the trigger are blocked // Then the display of the wrapper will also be affected, make width overflow to offset the wrapper if (position === 'top' || position === 'bottom') { isWidthOverFlow = isViewXOverFlowSideHalf && isContainerXOverFlowSideHalf || (clientRight < 0 || restClientRight < 0); } else { isWidthOverFlow = isViewXOverFlowSide && isContainerXOverFlowSide || (clientRight < 0 || restClientRight < 0); } } // 左右方向 left and right if (this.isLR(position)) { isWidthOverFlow = isViewXOverFlow && isContainerXOverFlow; // If clientTop or restClientTop less than 0, means that the top and bottom parts of the trigger are blocked // Then the display of the wrapper will also be affected, make height overflow to offset the wrapper if (position === 'left' || position === 'right') { isHeightOverFlow = isViewYOverFlowSideHalf && isContainerYOverFlowSideHalf || (clientTop < 0 || restClientTop < 0); } else { isHeightOverFlow = isViewYOverFlowSide && isContainerYOverFlowSide || (clientTop < 0 || restClientTop < 0); } } } return { position, isHeightOverFlow, isWidthOverFlow }; } delayHide = () => { const mouseLeaveDelay = this.getProp('mouseLeaveDelay'); this.clearDelayTimer(); if (mouseLeaveDelay > 0) { this._timer = setTimeout(() => { // console.log('delayHide for ', mouseLeaveDelay, ' ms, ', ...args); this.hide(); this.clearDelayTimer(); }, mouseLeaveDelay); } else { this.hide(); } }; hide = () => { this.clearDelayTimer(); this._togglePortalVisible(false); this._adapter.off('portalInserted'); this._adapter.off('positionUpdated'); }; _bindScrollEvent() { this._adapter.registerScrollHandler(() => this.calcPosition()); // 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 // (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 } unBindScrollEvent() { this._adapter.unregisterScrollHandler(); } _initContainerPosition() { this._adapter.updateContainerPosition(); } handleContainerKeydown = (event: any) => { const { guardFocus, closeOnEsc } = this.getProps(); switch (event && event.key) { case "Escape": closeOnEsc && this._handleEscKeyDown(event); break; case "Tab": if (guardFocus) { const container = this._adapter.getContainer(); const focusableElements = this._adapter.getFocusableElements(container); const focusableNum = focusableElements.length; if (focusableNum) { // Shift + Tab will move focus backward if (event.shiftKey) { this._handleContainerShiftTabKeyDown(focusableElements, event); } else { this._handleContainerTabKeyDown(focusableElements, event); } } } break; default: break; } } _handleTriggerKeydown(event: any) { const { closeOnEsc, disableArrowKeyDown } = this.getProps(); const container = this._adapter.getContainer(); const focusableElements = this._adapter.getFocusableElements(container); const focusableNum = focusableElements.length; switch (event && event.key) { case "Escape": handlePrevent(event); closeOnEsc && this._handleEscKeyDown(event); break; case "ArrowUp": // when disableArrowKeyDown is true, disable tooltip's arrow keyboard event action !disableArrowKeyDown && focusableNum && this._handleTriggerArrowUpKeydown(focusableElements, event); break; case "ArrowDown": !disableArrowKeyDown && focusableNum && this._handleTriggerArrowDownKeydown(focusableElements, event); break; default: break; } } /** * focus trigger * * when trigger is 'focus' or 'hover', onFocus is bind to show popup * if we focus trigger, popup will show again * * 如果 trigger 是 focus 或者 hover,则它绑定了 onFocus,这里我们如果重新 focus 的话,popup 会再次打开 * 因此 returnFocusOnClose 只支持 click trigger */ focusTrigger() { const { trigger, returnFocusOnClose, preventScroll } = this.getProps(); if (returnFocusOnClose && trigger !== 'custom') { const triggerNode = this._adapter.getTriggerNode(); if (triggerNode && 'focus' in triggerNode) { triggerNode.focus({ preventScroll }); } } } _handleEscKeyDown(event: any) { const { trigger } = this.getProps(); if (trigger !== 'custom') { // Move the focus into the trigger first and then close the pop-up layer // 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 this.focusTrigger(); this.hide(); } this._adapter.notifyEscKeydown(event); } _handleContainerTabKeyDown(focusableElements: any[], event: any) { const { preventScroll } = this.getProps(); const activeElement = this._adapter.getActiveElement(); const isLastCurrentFocus = focusableElements[focusableElements.length - 1] === activeElement; if (isLastCurrentFocus) { focusableElements[0].focus({ preventScroll }); event.preventDefault(); // prevent browser default tab move behavior } } _handleContainerShiftTabKeyDown(focusableElements: any[], event: any) { const { preventScroll } = this.getProps(); const activeElement = this._adapter.getActiveElement(); const isFirstCurrentFocus = focusableElements[0] === activeElement; if (isFirstCurrentFocus) { focusableElements[focusableElements.length - 1].focus({ preventScroll }); event.preventDefault(); // prevent browser default tab move behavior } } _handleTriggerArrowDownKeydown(focusableElements: any[], event: any) { const { preventScroll } = this.getProps(); focusableElements[0].focus({ preventScroll }); event.preventDefault(); // prevent browser default scroll behavior } _handleTriggerArrowUpKeydown(focusableElements: any[], event: any) { const { preventScroll } = this.getProps(); focusableElements[focusableElements.length - 1].focus({ preventScroll }); event.preventDefault(); // prevent browser default scroll behavior } }