Files
v10/packages/react/src/components/Tooltip.tsx
T

367 lines
11 KiB
TypeScript

import type { ReactNode, RefObject } from 'react';
import { contains, getBoundingClientRectWithoutTransform, getInBoundsAdjustments } from '@videojs/utils/dom';
import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
type Placement = 'top' | 'top-start' | 'top-end';
interface UpdatePositioningProps {
side: Placement;
sideOffset: number;
collisionPadding: number;
}
type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted';
interface TooltipContextType {
popupRef: RefObject<HTMLElement | null>;
triggerRef: RefObject<HTMLElement | null>;
open: boolean;
transitionStatus: TransitionStatus;
updatePositioning: (props: UpdatePositioningProps) => void;
trackCursorAxis?: 'x' | 'y' | 'both' | undefined;
placement: Placement;
sideOffset: number;
collisionPadding: number;
}
interface TooltipRootProps {
delay?: number;
closeDelay?: number;
children: ReactNode;
trackCursorAxis?: 'x' | 'y' | 'both';
}
interface TooltipTriggerProps {
children: ReactNode;
}
interface TooltipPositionerProps {
side?: Placement;
sideOffset?: number;
collisionPadding?: number;
children: ReactNode;
}
interface TooltipPopupProps {
id?: string;
className?: string;
children: ReactNode;
}
const TooltipContext = createContext<TooltipContextType | null>(null);
function useTooltipContext(): TooltipContextType {
const context = useContext(TooltipContext);
if (!context) {
throw new Error('Tooltip components must be used within TooltipRoot');
}
return context;
}
function TooltipRoot({ delay = 0, closeDelay = 0, trackCursorAxis, children }: TooltipRootProps): JSX.Element {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<Placement>('top');
const [sideOffset, setSideOffset] = useState(0);
const [collisionPadding, setCollisionPadding] = useState(0);
const [transitionStatus, setTransitionStatus] = useState<TransitionStatus>('initial');
const popupRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pointerPositionRef = useRef({ x: 0, y: 0 });
const updatePositioning = useCallback(({ side, sideOffset, collisionPadding }: UpdatePositioningProps) => {
setPlacement(side);
setSideOffset(sideOffset);
setCollisionPadding(collisionPadding);
}, []);
const clearHoverTimeout = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}, []);
const checkCollision = useCallback(() => {
if (!popupRef.current || !triggerRef.current || !open) return;
const mediaContainer = popupRef.current.closest('[data-media-container]') as HTMLElement | null;
if (!mediaContainer) return;
const popupRect = getBoundingClientRectWithoutTransform(popupRef.current);
const boundsRect = getBoundingClientRectWithoutTransform(mediaContainer);
const { x } = getInBoundsAdjustments(popupRect, boundsRect, collisionPadding);
if (x !== 0) {
if (trackCursorAxis) {
const currentLeft = Number.parseFloat(popupRef.current.style.left || '0');
popupRef.current.style.setProperty('left', `${currentLeft + x}px`);
} else {
popupRef.current.style.setProperty('translate', `${x}px -100%`);
}
} else {
if (trackCursorAxis) {
popupRef.current.style.setProperty('translate', '-50% -100%');
} else {
popupRef.current.style.setProperty('translate', '0 -100%');
}
}
}, [open, collisionPadding, trackCursorAxis]);
const updatePosition = useCallback(() => {
if (open && trackCursorAxis && popupRef.current) {
popupRef.current.style.setProperty('left', `${pointerPositionRef.current.x}px`);
checkCollision();
}
}, [open, trackCursorAxis, checkCollision]);
const setOpenState = useCallback(
(newOpen: boolean) => {
if (open === newOpen) return;
setOpen(newOpen);
if (newOpen) {
setTransitionStatus('initial');
if (popupRef.current) {
popupRef.current.showPopover();
}
requestAnimationFrame(() => {
setTransitionStatus('open');
checkCollision();
});
} else {
setTransitionStatus('close');
}
},
[open, checkCollision],
);
useEffect(() => {
if (!popupRef.current || open) return;
const transitions = popupRef.current.getAnimations().filter(anim => anim instanceof CSSTransition);
if (transitions.length > 0) {
Promise.all(transitions.map(t => t.finished))
.then(() => popupRef.current?.hidePopover())
.catch(() => popupRef.current?.hidePopover());
} else {
popupRef.current.hidePopover();
}
}, [open, transitionStatus]);
useEffect(() => {
const trigger = triggerRef.current;
const popup = popupRef.current;
if (!trigger || !popup) return;
const abortController = new AbortController();
const { signal } = abortController;
const handlePointerEnter = () => {
clearHoverTimeout();
hoverTimeoutRef.current = setTimeout(() => {
setOpenState(true);
}, delay);
};
const handlePointerLeave = () => {
clearHoverTimeout();
hoverTimeoutRef.current = setTimeout(() => {
setOpenState(false);
}, closeDelay);
};
const handlePointerMove = (event: PointerEvent) => {
if (trackCursorAxis) {
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
if (open) {
updatePosition();
}
}
};
const handleFocusIn = () => {
setOpenState(true);
};
const handleFocusOut = (event: FocusEvent) => {
const relatedTarget = event.relatedTarget as HTMLElement;
if (relatedTarget && popup && contains(popup, relatedTarget)) return;
setOpenState(false);
};
if (globalThis.matchMedia?.('(hover: hover)')?.matches) {
// Event listeners are automatically removed when AbortController is aborted
trigger.addEventListener('pointerenter', handlePointerEnter, { signal });
trigger.addEventListener('pointerleave', handlePointerLeave, { signal });
if (trackCursorAxis) {
trigger.addEventListener('pointermove', handlePointerMove, { signal });
}
}
// Event listeners are automatically removed when AbortController is aborted
trigger.addEventListener('focusin', handleFocusIn, { signal });
trigger.addEventListener('focusout', handleFocusOut, { signal });
return () => {
abortController.abort();
clearHoverTimeout();
};
}, [delay, closeDelay, trackCursorAxis, open, setOpenState, clearHoverTimeout, updatePosition]);
useEffect(() => {
if (!popupRef.current || !triggerRef.current) return;
const popup = popupRef.current;
const popupId = popup.id;
if (popupId) {
popup.style.setProperty('position-anchor', `--${popupId}`);
}
const [side, alignment] = placement.split('-');
popup.style.setProperty('top', `calc(anchor(${side}) - ${sideOffset}px)`);
if (trackCursorAxis) {
popup.style.setProperty('translate', `-50% -100%`);
} else {
popup.style.setProperty('translate', `0 -100%`);
popup.style.setProperty('justify-self', alignment === 'start'
? 'anchor-start'
: alignment === 'end'
? 'anchor-end'
: 'anchor-center');
}
}, [placement, sideOffset, trackCursorAxis]);
useEffect(() => {
if (!popupRef.current) return;
const resizeObserver = new ResizeObserver(checkCollision);
resizeObserver.observe(popupRef.current);
return () => {
resizeObserver.disconnect();
};
}, [checkCollision]);
const value: TooltipContextType = useMemo(
() => ({
popupRef,
triggerRef,
open,
transitionStatus,
updatePositioning,
trackCursorAxis,
placement,
sideOffset,
collisionPadding,
}),
[open, transitionStatus, updatePositioning, trackCursorAxis, placement, sideOffset, collisionPadding],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
function TooltipTrigger({ children }: TooltipTriggerProps): JSX.Element {
const { triggerRef, open } = useTooltipContext();
// eslint-disable-next-line react/no-clone-element, react/no-children-only
return cloneElement(Children.only(children) as JSX.Element, {
ref: triggerRef,
'data-popup-open': open ? '' : undefined,
});
}
function TooltipPositioner({
side = 'top',
sideOffset = 0,
collisionPadding = 0,
children,
}: TooltipPositionerProps): JSX.Element | null {
const { updatePositioning } = useTooltipContext();
useEffect(() => {
updatePositioning({ side, sideOffset, collisionPadding });
}, [side, sideOffset, collisionPadding, updatePositioning]);
return <>{children}</>;
}
function TooltipPopup({ id, className = '', children }: TooltipPopupProps): JSX.Element | null {
const { popupRef, triggerRef, transitionStatus, placement } = useTooltipContext();
const triggerElement = triggerRef.current;
const uniqueId = useId();
const popupId = id ?? uniqueId.replace(/^:([^:]+):$/, '«$1»');
useEffect(() => {
if (!popupRef.current || !triggerRef.current) return;
const popup = popupRef.current;
const trigger = triggerRef.current;
popup.setAttribute('popover', 'manual');
trigger.setAttribute('commandfor', popupId);
trigger.style.setProperty('anchor-name', `--${popupId}`);
}, [popupId, popupRef, triggerRef]);
// Copy data attributes from trigger element
const dataAttributes = useMemo(() => {
if (!triggerElement?.attributes) return {};
return Object.fromEntries(
Array.from(triggerElement.attributes)
.filter(attr => attr.name.startsWith('data-'))
.map(attr => [attr.name, attr.value]),
);
}, [triggerElement]);
return (
<div
ref={popupRef as RefObject<HTMLDivElement>}
id={popupId}
className={className}
role="tooltip"
{...dataAttributes}
data-side={placement}
data-starting-style={transitionStatus === 'initial' ? '' : undefined}
data-open={transitionStatus === 'initial' || transitionStatus === 'open' ? '' : undefined}
data-ending-style={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined}
data-closed={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined}
>
{children}
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const Tooltip: {
Root: typeof TooltipRoot;
Trigger: typeof TooltipTrigger;
Positioner: typeof TooltipPositioner;
Popup: typeof TooltipPopup;
} = {
Root: TooltipRoot,
Trigger: TooltipTrigger,
Positioner: TooltipPositioner,
Popup: TooltipPopup,
};
export default Tooltip;