feat: add tooltip core (#212)

This commit is contained in:
Wesley Luyten
2025-12-05 20:18:54 -06:00
committed by GitHub
parent 99fef78f63
commit cbf41ce4c7
16 changed files with 534 additions and 633 deletions
+81 -20
View File
@@ -6,13 +6,14 @@ import type { ConnectedComponent } from '../utils/component-factory';
import { Popover as CorePopover } from '@videojs/core';
import { Children, cloneElement, useCallback, useEffect, useId, useState } from 'react';
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
import { useMutationObserver } from '../utils/use-mutation-observer';
type Placement = CorePopoverState['placement'];
export type PopoverState = Prettify<
CorePopoverState & {
popupId: string | undefined;
updatePositioning: (placement: Placement, sideOffset: number) => void;
updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void;
}
>;
@@ -20,7 +21,7 @@ export type PopoverState = Prettify<
// ROOT COMPONENT
// ============================================================================
interface PopoverRootProps {
export interface PopoverRootProps {
openOnHover?: boolean;
delay?: number;
closeDelay?: number;
@@ -31,6 +32,7 @@ export function usePopoverRootState(props: PopoverRootProps): PopoverState {
const { openOnHover = false, delay = 0, closeDelay = 0 } = props;
const [placement, setPlacement] = useState<Placement>('top');
const [sideOffset, setSideOffset] = useState(5);
const [collisionPadding, setCollisionPadding] = useState(0);
const uniqueId = useId();
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
@@ -40,12 +42,17 @@ export function usePopoverRootState(props: PopoverRootProps): PopoverState {
closeDelay,
placement,
sideOffset,
collisionPadding,
});
const updatePositioning = useCallback((newPlacement: Placement, newSideOffset: number) => {
setPlacement(newPlacement);
setSideOffset(newSideOffset);
}, []);
const updatePositioning = useCallback(
(newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => {
setPlacement(newPlacement);
setSideOffset(newSideOffset);
setCollisionPadding(newCollisionPadding);
},
[],
);
return {
...coreState,
@@ -75,7 +82,7 @@ const PopoverRoot: ConnectedComponent<PopoverRootProps, typeof renderPopoverRoot
// TRIGGER COMPONENT
// ============================================================================
interface PopoverTriggerProps {
export interface PopoverTriggerProps {
children: ReactNode;
}
@@ -118,9 +125,11 @@ const PopoverTrigger: ConnectedComponent<PopoverTriggerProps, typeof renderPopov
// POSITIONER COMPONENT
// ============================================================================
interface PopoverPositionerProps {
export interface PopoverPositionerProps {
side?: Placement;
sideOffset?: number;
collisionPadding?: number;
collisionBoundary?: Element;
children: ReactNode;
}
@@ -128,12 +137,18 @@ export function usePopoverPositionerProps(
props: PopoverPositionerProps,
context: PopoverState,
): { children: ReactNode } {
const { side = 'top', sideOffset = 5, children } = props;
const { updatePositioning } = context;
const { side = 'top', sideOffset = 5, collisionPadding = 0, collisionBoundary, children } = props;
const { updatePositioning, _setCollisionBoundaryElement } = context;
useEffect(() => {
updatePositioning(side, sideOffset);
}, [side, sideOffset, updatePositioning]);
updatePositioning(side, sideOffset, collisionPadding);
}, [side, sideOffset, collisionPadding, updatePositioning]);
useEffect(() => {
if (collisionBoundary) {
_setCollisionBoundaryElement(collisionBoundary as HTMLElement);
}
}, [collisionBoundary, _setCollisionBoundaryElement]);
return { children };
}
@@ -149,14 +164,14 @@ const PopoverPositioner: ConnectedComponent<PopoverPositionerProps, typeof rende
// POPUP COMPONENT
// ============================================================================
interface PopoverPopupProps {
export interface PopoverPopupProps {
id?: string;
className?: string;
style?: React.CSSProperties;
children: ReactNode;
}
interface PopoverPopupRenderProps extends React.ComponentProps<'div'> {
export interface PopoverPopupRenderProps extends React.ComponentProps<'div'> {
children: ReactNode;
'data-side': Placement;
'data-starting-style': string | undefined;
@@ -167,8 +182,47 @@ interface PopoverPopupRenderProps extends React.ComponentProps<'div'> {
export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverState): PopoverPopupRenderProps {
const { className, style, children, id } = props;
const { _setPopoverElement, _transitionStatus, placement, popupId, sideOffset } = context;
const [side, alignment] = placement.split('-');
const {
_setPopoverElement,
_transitionStatus,
placement,
popupId,
_popoverStyle,
_triggerElement,
_popoverElement,
_setCollisionBoundaryElement,
_collisionBoundaryElement,
} = context;
// Set bounding box element for collision detection
useEffect(() => {
if (!_popoverElement) return;
// Only set bounding box if it's not already set (to allow collisionBoundary from Positioner to take precedence)
if (!_collisionBoundaryElement) {
const mediaContainer = _popoverElement.closest('[data-media-container]') as HTMLElement | null;
_setCollisionBoundaryElement(mediaContainer);
}
}, [_popoverElement, _collisionBoundaryElement, _setCollisionBoundaryElement]);
// Track data attributes from trigger element, updating when element or attributes change
const [dataAttrs, setDataAttrs] = useState<Record<string, string> | undefined>(() =>
getDataAttributes(_triggerElement),
);
// Update data attributes when trigger element changes
useEffect(() => {
setDataAttrs(getDataAttributes(_triggerElement));
}, [_triggerElement]);
// Update data attributes when attributes mutate
useMutationObserver(
_triggerElement,
() => {
setDataAttrs(getDataAttributes(_triggerElement));
},
{ attributes: true },
);
return {
ref: _setPopoverElement,
@@ -176,12 +230,10 @@ export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverS
className,
popover: 'manual' as const,
style: {
...(popupId ? { positionAnchor: `--${popupId}` as any } : {}),
top: `calc(anchor(${side}) - ${sideOffset}px)`,
translate: '0 -100%',
justifySelf: alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center',
..._popoverStyle,
...style,
} as React.CSSProperties,
...dataAttrs,
'data-side': placement,
'data-starting-style': _transitionStatus === 'initial' ? '' : undefined,
'data-open': _transitionStatus === 'initial' || _transitionStatus === 'open' ? '' : undefined,
@@ -191,6 +243,15 @@ export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverS
};
}
function getDataAttributes(element?: HTMLElement | null): Record<string, string> | undefined {
if (!element) return undefined;
return Object.fromEntries(
Array.from(element.attributes)
.filter(attr => attr.name.startsWith('data-'))
.map(attr => [attr.name, attr.value]),
);
}
export function renderPopoverPopup(props: PopoverPopupRenderProps): JSX.Element {
return <div {...props} />;
}
+133 -332
View File
@@ -1,365 +1,166 @@
import type { ReactNode, RefObject } from 'react';
import type { TooltipState as CoreTooltipState } from '@videojs/core';
import type { ReactNode } from 'react';
import type { Prettify } from '../types';
import type { ConnectedComponent } from '../utils/component-factory';
import type { PopoverPopupProps, PopoverPopupRenderProps, PopoverPositionerProps, PopoverRootProps } from './Popover';
import { contains, getBoundingClientRectWithoutTransform, getInBoundsAdjustments } from '@videojs/utils/dom';
import { Tooltip as CoreTooltip } from '@videojs/core';
import { cloneElement, useCallback, useId, useState } from 'react';
import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory';
import { usePopoverPopupProps, usePopoverPositionerProps, usePopoverTriggerProps } from './Popover';
import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
type Placement = CoreTooltipState['placement'];
type Placement = 'top' | 'top-start' | 'top-end';
export type TooltipState = Prettify<
CoreTooltipState & {
popupId: string | undefined;
updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void;
}
>;
interface UpdatePositioningProps {
side: Placement;
sideOffset: number;
collisionPadding: number;
// ============================================================================
// ROOT COMPONENT
// ============================================================================
interface TooltipRootProps extends PopoverRootProps {
trackCursorAxis?: 'x';
}
type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted';
export function useTooltipRootState(props: TooltipRootProps): TooltipState {
const { delay = 0, closeDelay = 0, trackCursorAxis } = props;
const [placement, setPlacement] = useState<Placement>('top');
const [sideOffset, setSideOffset] = useState(0);
const [collisionPadding, setCollisionPadding] = useState(0);
const uniqueId = useId();
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
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;
popupId: string | undefined;
const coreState = useCore<CoreTooltip>(CoreTooltip, {
delay,
closeDelay,
placement,
sideOffset,
collisionPadding,
trackCursorAxis,
});
const updatePositioning = useCallback(
(newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => {
setPlacement(newPlacement);
setSideOffset(newSideOffset);
setCollisionPadding(newCollisionPadding);
},
[],
);
return {
...coreState,
popupId,
updatePositioning,
};
}
interface TooltipRootProps {
delay?: number;
closeDelay?: number;
children: ReactNode;
trackCursorAxis?: 'x' | 'y' | 'both';
export function useTooltipRootProps(props: TooltipRootProps, _state: TooltipState): { children: ReactNode } {
return {
children: props.children,
};
}
export function renderTooltipRoot(props: { children: ReactNode }): JSX.Element {
return <>{props.children}</>;
}
const TooltipRoot: ConnectedComponent<TooltipRootProps, typeof renderTooltipRoot> = toConnectedComponent(
useTooltipRootState,
useTooltipRootProps,
renderTooltipRoot,
'Tooltip.Root',
);
// ============================================================================
// TRIGGER COMPONENT
// ============================================================================
interface TooltipTriggerProps {
children: ReactNode;
}
interface TooltipPositionerProps {
side?: Placement;
sideOffset?: number;
collisionPadding?: number;
children: ReactNode;
export function useTooltipTriggerProps(
props: TooltipTriggerProps,
context: TooltipState,
): { child: JSX.Element; triggerProps: Record<string, any> } {
return usePopoverTriggerProps(props, context);
}
interface TooltipPopupProps {
id?: string;
className?: string;
style?: React.CSSProperties;
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 uniqueId = useId();
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
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 [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,
popupId,
}),
[open, transitionStatus, updatePositioning, trackCursorAxis, placement, sideOffset, collisionPadding, popupId],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
function TooltipTrigger({ children }: TooltipTriggerProps): JSX.Element {
const { triggerRef, open, popupId } = useTooltipContext();
const child = Children.only(children) as JSX.Element;
const existingStyle = (child.props as { style?: React.CSSProperties })?.style || {};
export function renderTooltipTrigger(props: { child: JSX.Element; triggerProps: Record<string, any> }): JSX.Element {
// eslint-disable-next-line react/no-clone-element
return cloneElement(child, {
ref: triggerRef,
'data-popup-open': open ? '' : undefined,
commandfor: popupId ?? undefined,
style: {
...existingStyle,
...(popupId ? { anchorName: `--${popupId}` as any } : {}),
},
});
return cloneElement(props.child, props.triggerProps);
}
function TooltipPositioner({
side = 'top',
sideOffset = 0,
collisionPadding = 0,
children,
}: TooltipPositionerProps): JSX.Element | null {
const { updatePositioning } = useTooltipContext();
const TooltipTrigger: ConnectedComponent<TooltipTriggerProps, typeof renderTooltipTrigger> = toContextComponent(
useTooltipTriggerProps,
renderTooltipTrigger,
'Tooltip.Trigger',
);
useEffect(() => {
updatePositioning({ side, sideOffset, collisionPadding });
}, [side, sideOffset, collisionPadding, updatePositioning]);
// ============================================================================
// POSITIONER COMPONENT
// ============================================================================
return <>{children}</>;
interface TooltipPositionerProps extends PopoverPositionerProps {}
export function useTooltipPositionerProps(
props: TooltipPositionerProps,
context: TooltipState,
): { children: ReactNode } {
return usePopoverPositionerProps(props, context);
}
function TooltipPopup({ className = '', style, children }: TooltipPopupProps): JSX.Element | null {
const { popupRef, triggerRef, transitionStatus, placement, popupId } = useTooltipContext();
const triggerElement = triggerRef.current;
// 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 ?? undefined}
className={className}
role="tooltip"
popover="manual"
style={{
...(popupId ? { positionAnchor: `--${popupId}` as any } : {}),
...style,
}}
{...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>
);
export function renderTooltipPositioner(props: { children: ReactNode }): JSX.Element {
return <>{props.children}</>;
}
// eslint-disable-next-line react-refresh/only-export-components
export const Tooltip: {
const TooltipPositioner: ConnectedComponent<TooltipPositionerProps, typeof renderTooltipPositioner>
= toContextComponent(useTooltipPositionerProps, renderTooltipPositioner, 'Tooltip.Positioner');
// ============================================================================
// POPUP COMPONENT
// ============================================================================
interface TooltipPopupProps extends PopoverPopupProps {}
interface TooltipPopupRenderProps extends PopoverPopupRenderProps {}
export function useTooltipPopupProps(props: TooltipPopupProps, context: TooltipState): TooltipPopupRenderProps {
return usePopoverPopupProps(props, context);
}
export function renderTooltipPopup(props: TooltipPopupRenderProps): JSX.Element {
return <div {...props} />;
}
const TooltipPopup: ConnectedComponent<TooltipPopupProps, typeof renderTooltipPopup> = toContextComponent(
useTooltipPopupProps,
renderTooltipPopup,
'Tooltip.Popup',
);
// ============================================================================
// EXPORTS
// ============================================================================
export const Tooltip = Object.assign(
{},
{
Root: TooltipRoot,
Trigger: TooltipTrigger,
Positioner: TooltipPositioner,
Popup: TooltipPopup,
},
) as {
Root: typeof TooltipRoot;
Trigger: typeof TooltipTrigger;
Positioner: typeof TooltipPositioner;
Popup: typeof TooltipPopup;
} = {
Root: TooltipRoot,
Trigger: TooltipTrigger,
Positioner: TooltipPositioner,
Popup: TooltipPopup,
};
export default Tooltip;
@@ -0,0 +1,43 @@
import type { RefObject } from 'react';
import { useEffect, useRef } from 'react';
/**
* Custom React hook for observing DOM mutations using MutationObserver
*
* @param target - Element to observe (ref or element)
* @param callback - Function called when mutations occur
* @param options - MutationObserver options
*/
export function useMutationObserver<T extends Element = Element>(
target: RefObject<T> | T | null | undefined,
callback: MutationCallback,
options: MutationObserverInit = {
attributes: true,
childList: true,
subtree: true,
},
): void {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const element = target && 'current' in target ? target.current : target;
if (!element || !(element instanceof Element)) {
return;
}
const observer = new MutationObserver((mutations, obs) => {
callbackRef.current(mutations, obs);
});
observer.observe(element, options);
return () => {
observer.disconnect();
};
}, [target, options]);
}