mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 10:23:32 +00:00
367 lines
11 KiB
TypeScript
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;
|