import type { PopoverState as CorePopoverState } from '@videojs/core-preview'; import type { ReactNode } from 'react'; import type { Prettify } from '../types'; import type { ConnectedComponent } from '../utils/component-factory'; import { Popover as CorePopover } from '@videojs/core-preview'; 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, collisionPadding: number) => void; } >; // ============================================================================ // ROOT COMPONENT // ============================================================================ export interface PopoverRootProps { openOnHover?: boolean; delay?: number; closeDelay?: number; children: ReactNode; } export function usePopoverRootState(props: PopoverRootProps): PopoverState { const { openOnHover = false, delay = 0, closeDelay = 0 } = props; const [placement, setPlacement] = useState('top'); const [sideOffset, setSideOffset] = useState(5); const [collisionPadding, setCollisionPadding] = useState(0); const uniqueId = useId(); const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); const coreState = useCore(CorePopover, { openOnHover, delay, closeDelay, placement, sideOffset, collisionPadding, }); const updatePositioning = useCallback( (newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => { setPlacement(newPlacement); setSideOffset(newSideOffset); setCollisionPadding(newCollisionPadding); }, [], ); return { ...coreState, popupId, updatePositioning, }; } export function usePopoverRootProps(props: PopoverRootProps, _state: PopoverState): { children: ReactNode } { return { children: props.children, }; } export function renderPopoverRoot(props: { children: ReactNode }): JSX.Element { return <>{props.children}; } const PopoverRoot: ConnectedComponent = toConnectedComponent( usePopoverRootState, usePopoverRootProps, renderPopoverRoot, 'Popover.Root', ); // ============================================================================ // TRIGGER COMPONENT // ============================================================================ export interface PopoverTriggerProps { children: ReactNode; } export function usePopoverTriggerProps( props: PopoverTriggerProps, context: PopoverState, ): { child: JSX.Element; triggerProps: Record } { const { children } = props; const { _setTriggerElement, _open, popupId } = context; const child = Children.only(children) as JSX.Element; const existingStyle = (child.props as { style?: React.CSSProperties })?.style || {}; return { child, triggerProps: { ref: _setTriggerElement, 'data-popup-open': _open ? '' : undefined, commandfor: popupId ?? undefined, style: { ...existingStyle, ...(popupId ? { anchorName: `--${popupId}` as any } : {}), }, }, }; } export function renderPopoverTrigger(props: { child: JSX.Element; triggerProps: Record }): JSX.Element { return cloneElement(props.child, props.triggerProps); } const PopoverTrigger: ConnectedComponent = toContextComponent( usePopoverTriggerProps, renderPopoverTrigger, 'Popover.Trigger', ); // ============================================================================ // POSITIONER COMPONENT // ============================================================================ export interface PopoverPositionerProps { side?: Placement; sideOffset?: number; collisionPadding?: number; collisionBoundary?: Element; children: ReactNode; } export function usePopoverPositionerProps( props: PopoverPositionerProps, context: PopoverState, ): { children: ReactNode } { const { side = 'top', sideOffset = 5, collisionPadding = 0, collisionBoundary, children } = props; const { updatePositioning, _setCollisionBoundaryElement } = context; useEffect(() => { updatePositioning(side, sideOffset, collisionPadding); }, [side, sideOffset, collisionPadding, updatePositioning]); useEffect(() => { if (collisionBoundary) { _setCollisionBoundaryElement(collisionBoundary as HTMLElement); } }, [collisionBoundary, _setCollisionBoundaryElement]); return { children }; } export function renderPopoverPositioner(props: { children: ReactNode }): JSX.Element { return <>{props.children}; } const PopoverPositioner: ConnectedComponent = toContextComponent(usePopoverPositionerProps, renderPopoverPositioner, 'Popover.Positioner'); // ============================================================================ // POPUP COMPONENT // ============================================================================ export interface PopoverPopupProps { id?: string; className?: string; style?: React.CSSProperties; children: ReactNode; } export interface PopoverPopupRenderProps extends React.ComponentProps<'div'> { children: ReactNode; 'data-side': Placement; 'data-starting-style': string | undefined; 'data-open': string | undefined; 'data-ending-style': string | undefined; 'data-closed': string | undefined; } export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverState): PopoverPopupRenderProps { const { className, style, children, id } = props; 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 | 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, id: id ?? popupId ?? undefined, className, popover: 'manual' as const, style: { ..._popoverStyle, ...style, } as React.CSSProperties, ...dataAttrs, '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, }; } function getDataAttributes(element?: HTMLElement | null): Record | 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
; } const PopoverPopup: ConnectedComponent = toContextComponent( usePopoverPopupProps, renderPopoverPopup, 'Popover.Popup', ); // ============================================================================ // EXPORTS // ============================================================================ export const Popover = Object.assign( {}, { Root: PopoverRoot, Trigger: PopoverTrigger, Positioner: PopoverPositioner, Popup: PopoverPopup, }, ) as { Root: typeof PopoverRoot; Trigger: typeof PopoverTrigger; Positioner: typeof PopoverPositioner; Popup: typeof PopoverPopup; }; export default Popover;