import type { DPopupPlacement } from '../../utils/position';
import type { DRefExtra } from '@react-devui/hooks/useRefExtra';
import { isUndefined } from 'lodash';
import React, { useImperativeHandle, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useEvent, useEventCallback, useId, useRefExtra } from '@react-devui/hooks';
import { getClassName } from '@react-devui/utils';
import { useMaxIndex, useDValue } from '../../hooks';
import { checkNoExpandedEl, getPopupPosition, registerComponentMate } from '../../utils';
import { ESC_CLOSABLE_DATA } from '../../utils/checkNoExpandedEl';
import { DPopup } from '../_popup';
import { DTransition } from '../_transition';
import { ROOT_DATA, useComponentConfig, usePrefixConfig } from '../root';
export interface DTooltipRef {
updatePosition: () => void;
}
export interface DTooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
children: React.ReactElement;
dVisible?: boolean;
dInitialVisible?: boolean;
dTrigger?: 'hover' | 'click';
dContainer?: DRefExtra | false;
dPlacement?: DPopupPlacement;
dEscClosable?: boolean;
dArrow?: boolean;
dDistance?: number;
dInWindow?: number | false;
dMouseEnterDelay?: number;
dMouseLeaveDelay?: number;
dZIndex?: number | string;
dTitle: React.ReactNode;
onVisibleChange?: (visible: boolean) => void;
afterVisibleChange?: (visible: boolean) => void;
}
const TTANSITION_DURING = { enter: 86, leave: 100 };
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DTooltip' as const });
function Tooltip(props: DTooltipProps, ref: React.ForwardedRef<DTooltipRef>): JSX.Element | null {
const {
children,
dVisible,
dInitialVisible = false,
dTrigger = 'hover',
dContainer,
dPlacement = 'top',
dEscClosable = true,
dArrow = true,
dDistance = 10,
dInWindow = false,
dMouseEnterDelay = 150,
dMouseLeaveDelay = 200,
dZIndex,
dTitle,
onVisibleChange,
afterVisibleChange,
...restProps
} = useComponentConfig(COMPONENT_NAME, props);
const dPrefix = usePrefixConfig();
const windowRef = useRefExtra(() => window);
const triggerRef = useRefExtra<HTMLElement>(() => document.querySelector(`[aria-describedby="${id}"]`));
const popupRef = useRef<HTMLDivElement>(null);
const containerRef = useRefExtra(
isUndefined(dContainer)
? () => {
let el = document.getElementById(`${dPrefix}tooltip-root`);
if (!el) {
el = document.createElement('div');
el.id = `${dPrefix}tooltip-root`;
document.body.appendChild(el);
}
return el;
}
: dContainer === false
? () => {
return triggerRef.current?.parentElement ?? null;
}
: dContainer,
true
);
const uniqueId = useId();
const id = restProps.id ?? `${dPrefix}tooltip-${uniqueId}`;
const [visible, changeVisible] = useDValue<boolean>(dInitialVisible, dVisible, onVisibleChange);
const maxZIndex = useMaxIndex(visible);
const zIndex = (() => {
if (isUndefined(dZIndex)) {
if (isUndefined(dContainer)) {
return maxZIndex;
} else {
return `var(--${dPrefix}zindex-absolute)`;
}
} else {
return dZIndex;
}
})();
const [popupPositionStyle, setPopupPositionStyle] = useState<React.CSSProperties>({
top: '-200vh',
left: '-200vw',
});
const [placement, setPlacement] = useState<DPopupPlacement>(dPlacement);
const [transformOrigin, setTransformOrigin] = useState<string>();
const updatePosition = useEventCallback(() => {
if (visible && popupRef.current && triggerRef.current) {
let currentPlacement = dPlacement;
let space: [number, number, number, number] = [0, 0, 0, 0];
if (!isUndefined(dContainer) && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
space = [
containerRect.top,
ROOT_DATA.pageSize.width - containerRect.left - containerRect.width,
ROOT_DATA.pageSize.height - containerRect.top - containerRect.height,
containerRect.left,
];
}
const position = getPopupPosition(
popupRef.current,
triggerRef.current,
{
placement: dPlacement,
offset: dDistance,
inWindow: dInWindow,
},
space
);
if (position) {
currentPlacement = position.placement;
setPlacement(position.placement);
setPopupPositionStyle({
top: position.top,
left: position.left,
});
} else {
const position = getPopupPosition(popupRef.current, triggerRef.current, {
placement,
offset: dDistance,
inWindow: dInWindow,
});
setPopupPositionStyle({
top: position.top,
left: position.left,
});
}
let transformOrigin = 'center bottom';
switch (currentPlacement) {
case 'top':
transformOrigin = 'center bottom';
break;
case 'top-left':
transformOrigin = '20px bottom';
break;
case 'top-right':
transformOrigin = 'calc(100% - 20px) bottom';
break;
case 'right':
transformOrigin = 'left center';
break;
case 'right-top':
transformOrigin = 'left 12px';
break;
case 'right-bottom':
transformOrigin = 'left calc(100% - 12px)';
break;
case 'bottom':
transformOrigin = 'center top';
break;
case 'bottom-left':
transformOrigin = '20px top';
break;
case 'bottom-right':
transformOrigin = 'calc(100% - 20px) top';
break;
case 'left':
transformOrigin = 'right center';
break;
case 'left-top':
transformOrigin = 'right 12px';
break;
case 'left-bottom':
transformOrigin = 'right calc(100% - 12px)';
break;
default:
break;
}
setTransformOrigin(transformOrigin);
}
});
useEvent<KeyboardEvent>(
windowRef,
'keydown',
(e) => {
if (e.code === 'Escape' && popupRef.current && checkNoExpandedEl(popupRef.current)) {
changeVisible(false);
}
},
{},
!dEscClosable || !visible
);
useImperativeHandle(
ref,
() => ({
updatePosition,
}),
[updatePosition]
);
return (
<DPopup
dVisible={visible}
dTrigger={dTrigger}
dMouseEnterDelay={dMouseEnterDelay}
dMouseLeaveDelay={dMouseLeaveDelay}
dUpdatePosition={{
fn: updatePosition,
triggerRef,
popupRef,
extraScrollRefs: [isUndefined(dContainer) ? undefined : containerRef],
}}
onVisibleChange={changeVisible}
>
{({ renderTrigger, renderPopup }) => (
<>
{renderTrigger(
React.cloneElement(children, {
'aria-describedby': id,
[ESC_CLOSABLE_DATA]: dEscClosable && visible,
})
)}
{containerRef.current &&
ReactDOM.createPortal(
<DTransition
dIn={visible}
dDuring={TTANSITION_DURING}
onEnter={updatePosition}
afterEnter={() => {
afterVisibleChange?.(true);
}}
afterLeave={() => {
afterVisibleChange?.(false);
}}
>
{(state) => {
let transitionStyle: React.CSSProperties = {};
switch (state) {
case 'enter':
transitionStyle = { transform: 'scale(0.3)', opacity: 0 };
break;
case 'entering':
transitionStyle = {
transition: ['transform', 'opacity'].map((attr) => `${attr} ${TTANSITION_DURING.enter}ms ease-out`).join(', '),
transformOrigin,
};
break;
case 'leaving':
transitionStyle = {
transform: 'scale(0.3)',
opacity: 0,
transition: ['transform', 'opacity'].map((attr) => `${attr} ${TTANSITION_DURING.leave}ms ease-in`).join(', '),
transformOrigin,
};
break;
case 'leaved':
transitionStyle = { display: 'none' };
break;
default:
break;
}
return renderPopup(
<div
{...restProps}
ref={popupRef}
id={id}
className={getClassName(restProps.className, `${dPrefix}tooltip`, `${dPrefix}tooltip--` + placement)}
style={{
...restProps.style,
...popupPositionStyle,
zIndex,
...transitionStyle,
}}
role="tooltip"
>
{dArrow && <div className={`${dPrefix}tooltip__arrow`}></div>}
{dTitle}
</div>
);
}}
</DTransition>,
containerRef.current
)}
</>
)}
</DPopup>
);
}
export const DTooltip = React.forwardRef(Tooltip);