feat(react): add tooltip component (#736)

This commit is contained in:
rahim
2026-03-04 23:20:16 -08:00
committed by GitHub
parent e9fbaece87
commit ae754eec8f
10 changed files with 406 additions and 0 deletions
+1
View File
@@ -53,6 +53,7 @@ export type { SliderValueProps } from './ui/slider/slider-value';
export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail';
export { Time } from './ui/time';
export { TimeSlider } from './ui/time-slider';
export { Tooltip, type TooltipContextValue, useTooltipContext } from './ui/tooltip';
export { VolumeSlider } from './ui/volume-slider';
// Utilities
+24
View File
@@ -0,0 +1,24 @@
'use client';
import type { StateAttrMap, TooltipCore } from '@videojs/core';
import type { TooltipApi } from '@videojs/core/dom';
import { createContext, useContext } from 'react';
export interface TooltipContextValue {
core: TooltipCore;
tooltip: TooltipApi;
state: TooltipCore.State;
stateAttrMap: StateAttrMap<TooltipCore.State>;
anchorName: string;
popupId: string;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
export const TooltipContextProvider = TooltipContext.Provider;
export function useTooltipContext(): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) throw new Error('Tooltip compound components must be used within a Tooltip.Root');
return ctx;
}
@@ -0,0 +1,17 @@
'use client';
import type { TooltipGroupCore } from '@videojs/core';
import { createContext, useContext } from 'react';
interface TooltipGroupContextValue {
group: TooltipGroupCore;
}
const TooltipGroupContext = createContext<TooltipGroupContextValue | null>(null);
export const TooltipGroupContextProvider = TooltipGroupContext.Provider;
/** Returns the nearest `TooltipGroupCore`, or `undefined` when used outside a `Tooltip.Provider`. */
export function useTooltipGroup(): TooltipGroupCore | undefined {
return useContext(TooltipGroupContext)?.group;
}
@@ -0,0 +1,5 @@
export { TooltipArrow as Arrow, type TooltipArrowProps as ArrowProps } from './tooltip-arrow';
export { TooltipPopup as Popup, type TooltipPopupProps as PopupProps } from './tooltip-popup';
export { TooltipProvider as Provider, type TooltipProviderProps as ProviderProps } from './tooltip-provider';
export { TooltipRoot as Root, type TooltipRootProps as RootProps } from './tooltip-root';
export { TooltipTrigger as Trigger, type TooltipTriggerProps as TriggerProps } from './tooltip-trigger';
+2
View File
@@ -0,0 +1,2 @@
export { type TooltipContextValue, useTooltipContext } from './context';
export * as Tooltip from './index.parts';
@@ -0,0 +1,22 @@
'use client';
import type { TooltipState } from '@videojs/core';
import type { UIComponentProps } from '../../utils/types';
import { createContextPart } from '../create-context-part';
import { useTooltipContext } from './context';
export interface TooltipArrowProps extends UIComponentProps<'div', TooltipState> {}
/** Decorative arrow pointing from the tooltip toward the trigger. Hidden from assistive technology. */
export const TooltipArrow = createContextPart<TooltipArrowProps, TooltipState>({
displayName: 'TooltipArrow',
tag: 'div',
useContext: useTooltipContext,
staticProps: { 'aria-hidden': 'true' as const },
});
export namespace TooltipArrow {
export type Props = TooltipArrowProps;
export type State = TooltipState;
}
@@ -0,0 +1,148 @@
'use client';
import type { TooltipState } from '@videojs/core';
import { TooltipCSSVars } from '@videojs/core';
import { getAnchorPositionStyle, resolveOffsets } from '@videojs/core/dom';
import { supportsAnchorPositioning } from '@videojs/utils/dom';
import type { CSSProperties } from 'react';
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { renderElement } from '../../utils/use-render';
import { useTooltipContext } from './context';
export interface TooltipPopupProps extends UIComponentProps<'div', TooltipState> {}
const POPUP_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 };
/** Container for the tooltip content. Positioned relative to the trigger using CSS anchor positioning with a JavaScript fallback. */
export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(function TooltipPopup(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, tooltip, state, stateAttrMap, anchorName, popupId } = useTooltipContext();
const internalRef = useRef<HTMLDivElement>(null);
const popupRef = useCallback(
(el: HTMLDivElement | null) => {
tooltip.setPopupElement(el);
if (el && supportsAnchorPositioning()) {
el.style.setProperty('position-anchor', `--${anchorName}`);
}
},
[tooltip, anchorName]
);
const composedRef = useComposedRefs(forwardedRef, popupRef, internalRef);
// --- Positioning ---
const posOpts = useMemo(() => ({ side: state.side, align: state.align }), [state.side, state.align]);
// CSS Anchor Positioning — computed from state, no measurement needed.
// `position-anchor` is set imperatively in the ref callback above
// because React's style prop silently drops unrecognised CSS properties.
const anchorStyle = useMemo(() => {
if (!supportsAnchorPositioning()) return null;
const { positionAnchor: _, ...rest } = getAnchorPositionStyle(
anchorName,
posOpts,
undefined,
undefined,
undefined,
undefined,
TooltipCSSVars
);
return rest as CSSProperties;
}, [anchorName, posOpts]);
// Manual fallback — measure rects after layout, before paint.
const [manualStyle, setManualStyle] = useState<CSSProperties | null>(null);
useLayoutEffect(() => {
if (supportsAnchorPositioning()) return;
if (!state.open) {
setManualStyle(null);
return;
}
function measure(): void {
const triggerEl = tooltip.triggerElement;
const popupEl = internalRef.current;
if (!triggerEl || !popupEl) return;
const triggerRect = triggerEl.getBoundingClientRect();
const popupRect = popupEl.getBoundingClientRect();
const boundaryRect = document.documentElement.getBoundingClientRect();
const offsets = resolveOffsets(popupEl, TooltipCSSVars);
setManualStyle(
getAnchorPositionStyle(
anchorName,
posOpts,
triggerRect,
popupRect,
boundaryRect,
offsets,
TooltipCSSVars
) as CSSProperties
);
}
measure();
// Recompute on scroll/resize so the tooltip tracks its trigger.
let rafId = 0;
function reposition(): void {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(measure);
}
window.addEventListener('scroll', reposition, { capture: true, passive: true });
window.addEventListener('resize', reposition);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [state.open, anchorName, posOpts, tooltip]);
// Anchor path uses computed styles; manual path uses measured styles;
// fallback resets UA [popover] defaults until positioning is computed.
const positioningStyle = anchorStyle ?? manualStyle ?? POPUP_RESET;
// --- Visibility ---
if (!state.open) {
return null;
}
// Remap DOM focus events to React synthetic event names.
const { onFocusOut, ...restPopupProps } = tooltip.popupProps;
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap,
ref: composedRef,
props: [
{
id: popupId,
style: positioningStyle,
...core.getPopupAttrs(state),
},
{ ...restPopupProps, onBlur: onFocusOut },
elementProps,
],
}
);
});
export namespace TooltipPopup {
export type Props = TooltipPopupProps;
export type State = TooltipState;
}
@@ -0,0 +1,22 @@
'use client';
import { TooltipGroupCore, type TooltipGroupProps } from '@videojs/core';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { TooltipGroupContextProvider } from './group-context';
export interface TooltipProviderProps extends TooltipGroupProps {
children?: ReactNode;
}
export function TooltipProvider({ delay, closeDelay, timeout, children }: TooltipProviderProps): ReactNode {
const [group] = useState(() => new TooltipGroupCore({ delay, closeDelay, timeout }));
group.setProps({ delay, closeDelay, timeout });
return <TooltipGroupContextProvider value={{ group }}>{children}</TooltipGroupContextProvider>;
}
export namespace TooltipProvider {
export type Props = TooltipProviderProps;
}
@@ -0,0 +1,108 @@
'use client';
import { type TooltipProps as CoreTooltipProps, TooltipCore, TooltipDataAttrs } from '@videojs/core';
import { createTooltip, createTransition, type TooltipChangeDetails } from '@videojs/core/dom';
import { useSnapshot } from '@videojs/store/react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useLatestRef } from '../../utils/use-latest-ref';
import { useSafeId } from '../../utils/use-safe-id';
import { TooltipContextProvider } from './context';
import { useTooltipGroup } from './group-context';
export interface TooltipRootProps extends CoreTooltipProps {
/** Called when the tooltip open state changes (fires immediately, before animations). */
onOpenChange?: (open: boolean, details: TooltipChangeDetails) => void;
/** Called after open/close animations complete. */
onOpenChangeComplete?: (open: boolean) => void;
children?: ReactNode;
}
export function TooltipRoot({
open: controlledOpen,
defaultOpen = TooltipCore.defaultProps.defaultOpen,
onOpenChange: onOpenChangeProp,
onOpenChangeComplete: onOpenChangeCompleteProp,
delay = TooltipCore.defaultProps.delay,
closeDelay = TooltipCore.defaultProps.closeDelay,
disableHoverablePopup = TooltipCore.defaultProps.disableHoverablePopup,
disabled = TooltipCore.defaultProps.disabled,
children,
...coreProps
}: TooltipRootProps): ReactNode {
const [core] = useState(() => new TooltipCore(coreProps));
core.setProps(coreProps);
const isControlled = controlledOpen !== undefined;
const groupFromContext = useTooltipGroup();
// Keep refs that always point to the latest values so the
// createTooltip closure never reads stale props.
const onOpenChangeRef = useLatestRef(onOpenChangeProp);
const onOpenChangeCompleteRef = useLatestRef(onOpenChangeCompleteProp);
const delayRef = useLatestRef(delay);
const closeDelayRef = useLatestRef(closeDelay);
const disableHoverablePopupRef = useLatestRef(disableHoverablePopup);
const disabledRef = useLatestRef(disabled);
const groupRef = useLatestRef(groupFromContext);
const [tooltip] = useState(() => {
const instance = createTooltip({
transition: createTransition(),
onOpenChange: (nextOpen: boolean, details: TooltipChangeDetails) => {
onOpenChangeRef.current?.(nextOpen, details);
},
onOpenChangeComplete: (nextOpen: boolean) => {
onOpenChangeCompleteRef.current?.(nextOpen);
},
delay: () => delayRef.current,
closeDelay: () => closeDelayRef.current,
disableHoverablePopup: () => disableHoverablePopupRef.current,
disabled: () => disabledRef.current,
group: () => groupRef.current,
});
// Apply defaultOpen on creation (uncontrolled only)
if (!isControlled && defaultOpen) {
instance.open();
}
return instance;
});
const anchorName = useSafeId();
const popupId = useSafeId('tooltip-');
// Sync controlled open prop -> internal input state.
useEffect(() => {
if (controlledOpen === undefined) return;
const { active: inputOpen } = tooltip.input.current;
if (controlledOpen === inputOpen) return;
if (controlledOpen) {
tooltip.open();
} else {
tooltip.close();
}
}, [controlledOpen, tooltip]);
// Cleanup on unmount
useEffect(() => () => tooltip.destroy(), [tooltip]);
const input = useSnapshot(tooltip.input);
core.setInput(input);
const state = core.getState();
return (
<TooltipContextProvider value={{ core, tooltip, state, stateAttrMap: TooltipDataAttrs, anchorName, popupId }}>
{children}
</TooltipContextProvider>
);
}
export namespace TooltipRoot {
export type Props = TooltipRootProps;
}
@@ -0,0 +1,57 @@
'use client';
import type { TooltipState } from '@videojs/core';
import { supportsAnchorPositioning } from '@videojs/utils/dom';
import { forwardRef, useCallback } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useTooltipContext } from './context';
export interface TooltipTriggerProps extends UIComponentProps<'button', TooltipState> {}
/** Element that triggers the tooltip on hover and focus. Renders a `<button>` element. */
export const TooltipTrigger = forwardRef<HTMLButtonElement, TooltipTriggerProps>(function TooltipTrigger(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, tooltip, state, stateAttrMap, anchorName, popupId } = useTooltipContext();
const triggerRef = useCallback(
(el: HTMLButtonElement | null) => {
tooltip.setTriggerElement(el);
if (el && supportsAnchorPositioning()) {
el.style.setProperty('anchor-name', `--${anchorName}`);
}
},
[tooltip, anchorName]
);
// Remap DOM focus events to React synthetic event names.
// createTooltip() uses onFocusIn/onFocusOut (matching DOM focusin/focusout),
// but React maps those to onFocus/onBlur.
const { onFocusIn, onFocusOut, ...restTriggerProps } = tooltip.triggerProps;
return renderElement(
'button',
{ render, className, style },
{
state,
stateAttrMap,
ref: [forwardedRef, triggerRef],
props: [
{
type: 'button' as const,
...core.getTriggerAttrs(state, popupId),
},
{ ...restTriggerProps, onFocus: onFocusIn, onBlur: onFocusOut },
elementProps,
],
}
);
});
export namespace TooltipTrigger {
export type Props = TooltipTriggerProps;
export type State = TooltipState;
}