mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add tooltip core (#212)
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user