diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index befab9e8..9c2835f7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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 diff --git a/packages/react/src/ui/tooltip/context.tsx b/packages/react/src/ui/tooltip/context.tsx new file mode 100644 index 00000000..9ba09d2a --- /dev/null +++ b/packages/react/src/ui/tooltip/context.tsx @@ -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; + anchorName: string; + popupId: string; +} + +const TooltipContext = createContext(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; +} diff --git a/packages/react/src/ui/tooltip/group-context.tsx b/packages/react/src/ui/tooltip/group-context.tsx new file mode 100644 index 00000000..7a7aad83 --- /dev/null +++ b/packages/react/src/ui/tooltip/group-context.tsx @@ -0,0 +1,17 @@ +'use client'; + +import type { TooltipGroupCore } from '@videojs/core'; +import { createContext, useContext } from 'react'; + +interface TooltipGroupContextValue { + group: TooltipGroupCore; +} + +const TooltipGroupContext = createContext(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; +} diff --git a/packages/react/src/ui/tooltip/index.parts.ts b/packages/react/src/ui/tooltip/index.parts.ts new file mode 100644 index 00000000..50ba190d --- /dev/null +++ b/packages/react/src/ui/tooltip/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/tooltip/index.ts b/packages/react/src/ui/tooltip/index.ts new file mode 100644 index 00000000..03cdbff3 --- /dev/null +++ b/packages/react/src/ui/tooltip/index.ts @@ -0,0 +1,2 @@ +export { type TooltipContextValue, useTooltipContext } from './context'; +export * as Tooltip from './index.parts'; diff --git a/packages/react/src/ui/tooltip/tooltip-arrow.tsx b/packages/react/src/ui/tooltip/tooltip-arrow.tsx new file mode 100644 index 00000000..483ec8c9 --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-arrow.tsx @@ -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({ + displayName: 'TooltipArrow', + tag: 'div', + useContext: useTooltipContext, + staticProps: { 'aria-hidden': 'true' as const }, +}); + +export namespace TooltipArrow { + export type Props = TooltipArrowProps; + export type State = TooltipState; +} diff --git a/packages/react/src/ui/tooltip/tooltip-popup.tsx b/packages/react/src/ui/tooltip/tooltip-popup.tsx new file mode 100644 index 00000000..e25970f2 --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-popup.tsx @@ -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(function TooltipPopup( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { core, tooltip, state, stateAttrMap, anchorName, popupId } = useTooltipContext(); + const internalRef = useRef(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(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; +} diff --git a/packages/react/src/ui/tooltip/tooltip-provider.tsx b/packages/react/src/ui/tooltip/tooltip-provider.tsx new file mode 100644 index 00000000..6ace9f8f --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-provider.tsx @@ -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 {children}; +} + +export namespace TooltipProvider { + export type Props = TooltipProviderProps; +} diff --git a/packages/react/src/ui/tooltip/tooltip-root.tsx b/packages/react/src/ui/tooltip/tooltip-root.tsx new file mode 100644 index 00000000..0b64d80a --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-root.tsx @@ -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 ( + + {children} + + ); +} + +export namespace TooltipRoot { + export type Props = TooltipRootProps; +} diff --git a/packages/react/src/ui/tooltip/tooltip-trigger.tsx b/packages/react/src/ui/tooltip/tooltip-trigger.tsx new file mode 100644 index 00000000..aae68e89 --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-trigger.tsx @@ -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 `