diff --git a/packages/core/src/components/popover.ts b/packages/core/src/components/popover.ts index 9d48ec32..3f569025 100644 --- a/packages/core/src/components/popover.ts +++ b/packages/core/src/components/popover.ts @@ -5,12 +5,12 @@ import { map } from 'nanostores'; type Placement = 'top' | 'top-start' | 'top-end'; export interface PopoverState { - open: boolean; openOnHover: boolean; delay: number; closeDelay: number; placement: Placement; sideOffset: number; + _open: boolean; _setTriggerElement: (element: HTMLElement | null) => void; _triggerElement: HTMLElement | null; _setPopoverElement: (element: HTMLElement | null) => void; @@ -21,7 +21,7 @@ export interface PopoverState { export class Popover { #hoverTimeout: ReturnType | null = null; #state = map({ - open: false, + _open: false, openOnHover: false, delay: 0, closeDelay: 0, @@ -113,13 +113,13 @@ export class Popover { } get #open(): boolean { - return this.getState().open; + return this.getState()._open; } #setOpen(open: boolean): void { if (this.#open === open) return; - this.setState({ open }); + this.setState({ _open: open }); if (open) { this.setState({ _transitionStatus: 'initial' }); @@ -132,14 +132,17 @@ export class Popover { } else { this.setState({ _transitionStatus: 'close' }); - const transitions = this.#popoverElement?.getAnimations().filter(anim => anim instanceof CSSTransition); - if (transitions && transitions.length > 0) { - Promise.all(transitions.map(t => t.finished)) - .then(() => this.#popoverElement?.hidePopover()) - .catch(() => this.#popoverElement?.hidePopover()); - } else { - this.#popoverElement?.hidePopover(); - } + // This requestAnimationFrame is required for React because the data- attributes are not updated immediately. + requestAnimationFrame(() => { + const transitions = this.#popoverElement?.getAnimations().filter(anim => anim instanceof CSSTransition); + if (transitions && transitions.length > 0) { + Promise.all(transitions.map(t => t.finished)) + .then(() => this.#popoverElement?.hidePopover()) + .catch(() => this.#popoverElement?.hidePopover()); + } else { + this.#popoverElement?.hidePopover(); + } + }); } } @@ -159,7 +162,7 @@ export class Popover { this.#addPointerMoveListener(); } - if (this.getState().open) { + if (this.getState()._open) { return; } diff --git a/packages/html/src/elements/popover.ts b/packages/html/src/elements/popover.ts index 6ad0b845..b0b182f1 100644 --- a/packages/html/src/elements/popover.ts +++ b/packages/html/src/elements/popover.ts @@ -1,14 +1,13 @@ -import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import type { Prettify } from '../types'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { Popover as CorePopover } from '@videojs/core'; - import { getDocumentOrShadowRoot } from '@videojs/utils/dom'; import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory'; type Placement = 'top' | 'top-start' | 'top-end'; -type PopoverState = Prettify>; +export type PopoverState = Prettify>; export const getPopoverState: StateHook = (element, _mediaStore) => { const coreState = getCoreState(CorePopover, getPropsFromAttrs(element)); diff --git a/packages/react/src/components/Popover.tsx b/packages/react/src/components/Popover.tsx index f7101741..a9155471 100644 --- a/packages/react/src/components/Popover.tsx +++ b/packages/react/src/components/Popover.tsx @@ -1,35 +1,24 @@ +import type { PopoverState as CorePopoverState } from '@videojs/core'; import type { ReactNode } from 'react'; +import type { Prettify } from '../types'; +import type { ConnectedComponent } from '../utils/component-factory'; -import { contains, safePolygon } from '@videojs/utils/dom'; +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 { - Children, - cloneElement, - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; +type Placement = CorePopoverState['placement']; -type Placement = 'top' | 'top-start' | 'top-end'; +export type PopoverState = Prettify< + CorePopoverState & { + popupId: string | undefined; + updatePositioning: (placement: Placement, sideOffset: number) => void; + } +>; -type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted'; - -interface PopoverContextType { - open: boolean; - setOpen: (open: boolean) => void; - popupRef: React.RefObject; - triggerRef: React.RefObject; - updatePositioning: (placement: Placement, sideOffset: number) => void; - transitionStatus: TransitionStatus; - placement: Placement; - sideOffset: number; - popupId: string | undefined; -} +// ============================================================================ +// ROOT COMPONENT +// ============================================================================ interface PopoverRootProps { openOnHover?: boolean; @@ -38,16 +27,128 @@ interface PopoverRootProps { 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 uniqueId = useId(); + const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); + + const coreState = useCore(CorePopover, { + openOnHover, + delay, + closeDelay, + placement, + sideOffset, + }); + + const updatePositioning = useCallback((newPlacement: Placement, newSideOffset: number) => { + setPlacement(newPlacement); + setSideOffset(newSideOffset); + }, []); + + 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 +// ============================================================================ + 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 { + // eslint-disable-next-line react/no-clone-element + return cloneElement(props.child, props.triggerProps); +} + +const PopoverTrigger: ConnectedComponent = toContextComponent( + usePopoverTriggerProps, + renderPopoverTrigger, + 'Popover.Trigger', +); + +// ============================================================================ +// POSITIONER COMPONENT +// ============================================================================ + interface PopoverPositionerProps { side?: Placement; sideOffset?: number; children: ReactNode; } +export function usePopoverPositionerProps( + props: PopoverPositionerProps, + context: PopoverState, +): { children: ReactNode } { + const { side = 'top', sideOffset = 5, children } = props; + const { updatePositioning } = context; + + useEffect(() => { + updatePositioning(side, sideOffset); + }, [side, sideOffset, updatePositioning]); + + return { children }; +} + +export function renderPopoverPositioner(props: { children: ReactNode }): JSX.Element { + return <>{props.children}; +} + +const PopoverPositioner: ConnectedComponent + = toContextComponent(usePopoverPositionerProps, renderPopoverPositioner, 'Popover.Positioner'); + +// ============================================================================ +// POPUP COMPONENT +// ============================================================================ + interface PopoverPopupProps { id?: string; className?: string; @@ -55,269 +156,68 @@ interface PopoverPopupProps { children: ReactNode; } -const PopoverContext = createContext(null); - -function usePopoverContext(): PopoverContextType { - const context = useContext(PopoverContext); - if (!context) { - throw new Error('Popover components must be used within PopoverRoot'); - } - return context; +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; } -function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children }: PopoverRootProps): JSX.Element { - const [open, setOpen] = useState(false); - const [placement, setPlacement] = useState('top'); - const [sideOffset, setSideOffset] = useState(5); - const [transitionStatus, setTransitionStatus] = useState('initial'); - const popupRef = useRef(null); - const triggerRef = useRef(null); - const hoverTimeoutRef = useRef | null>(null); - const pointerMoveHandlerRef = useRef<((event: MouseEvent) => void) | null>(null); - const uniqueId = useId(); - const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); +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 clearHoverTimeout = useCallback(() => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - }, []); - - const setOpenState = useCallback((newOpen: boolean) => { - if (open === newOpen) return; - - setOpen(newOpen); - - if (newOpen) { - setTransitionStatus('initial'); - if (popupRef.current) { - popupRef.current.showPopover(); - } - requestAnimationFrame(() => { - setTransitionStatus('open'); - }); - } else { - setTransitionStatus('close'); - } - }, [open]); - - 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]); - - const updatePositioning = useCallback((newPlacement: Placement, newSideOffset: number) => { - setPlacement(newPlacement); - setSideOffset(newSideOffset); - }, []); - - useEffect(() => { - const trigger = triggerRef.current; - const popup = popupRef.current; - if (!trigger || !popup) return; - - const abortController = new AbortController(); - const { signal } = abortController; - - const addPointerMoveListener = () => { - if (!globalThis.matchMedia?.('(hover: hover)')?.matches) return; - - if (!pointerMoveHandlerRef.current) { - pointerMoveHandlerRef.current = safePolygon({ blockPointerEvents: true })({ - placement, - elements: { - domReference: trigger, - floating: popup, - }, - x: 0, - y: 0, - onClose: () => { - if (pointerMoveHandlerRef.current) { - document.documentElement.removeEventListener('pointermove', pointerMoveHandlerRef.current); - } - clearHoverTimeout(); - - hoverTimeoutRef.current = setTimeout(() => { - setOpenState(false); - }, closeDelay); - }, - }); - } - - // Event listener is automatically removed when AbortController is aborted - document.documentElement.addEventListener('pointermove', pointerMoveHandlerRef.current, { signal }); - }; - - const handlePointerEnter = (event: PointerEvent) => { - if (!openOnHover) return; - - clearHoverTimeout(); - - if (event.currentTarget === popup) { - addPointerMoveListener(); - } - - if (open) { - return; - } - - hoverTimeoutRef.current = setTimeout(() => { - setOpenState(true); - }, delay); - }; - - const handlePointerLeave = () => { - if (!openOnHover) return; - addPointerMoveListener(); - }; - - 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 }); - popup.addEventListener('pointerenter', handlePointerEnter, { signal }); - } - - const handleFocusIn = () => { - setOpenState(true); - }; - - const handleFocusOut = (event: FocusEvent) => { - const relatedTarget = event.relatedTarget as HTMLElement; - if (relatedTarget && popup && contains(popup, relatedTarget)) return; - setOpenState(false); - }; - - // Event listeners are automatically removed when AbortController is aborted - trigger.addEventListener('focusin', handleFocusIn, { signal }); - trigger.addEventListener('focusout', handleFocusOut, { signal }); - popup.addEventListener('focusout', handleFocusOut, { signal }); - - return () => { - abortController.abort(); - clearHoverTimeout(); - - if (pointerMoveHandlerRef.current) { - document.documentElement.removeEventListener('pointermove', pointerMoveHandlerRef.current); - } - - pointerMoveHandlerRef.current = null; - }; - }, [openOnHover, delay, closeDelay, open, placement, setOpenState, clearHoverTimeout]); - - 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)`); - popup.style.setProperty('translate', `0 -100%`); - popup.style.setProperty('justify-self', alignment === 'start' - ? 'anchor-start' - : alignment === 'end' - ? 'anchor-end' - : 'anchor-center'); - }, [placement, sideOffset]); - - const value: PopoverContextType = useMemo(() => ({ - open, - setOpen: setOpenState, - popupRef, - triggerRef, - updatePositioning, - transitionStatus, - placement, - sideOffset, - popupId, - }), [open, setOpenState, updatePositioning, transitionStatus, placement, sideOffset, popupId]); - - return {children}; -} - -function PopoverTrigger({ children }: PopoverTriggerProps): JSX.Element { - const { triggerRef, open, popupId } = usePopoverContext(); - - const child = Children.only(children) as JSX.Element; - const existingStyle = (child.props as { style?: React.CSSProperties })?.style || {}; - - // eslint-disable-next-line react/no-clone-element - return cloneElement(child, { - ref: triggerRef, - 'data-popup-open': open ? '' : undefined, - commandfor: popupId ?? undefined, + return { + ref: _setPopoverElement, + id: id ?? popupId ?? undefined, + className, + popover: 'manual' as const, style: { - ...existingStyle, - ...(popupId ? { anchorName: `--${popupId}` as any } : {}), - }, - }); + ...(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', + ...style, + } as React.CSSProperties, + '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 PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPositionerProps): JSX.Element | null { - const { updatePositioning } = usePopoverContext(); - - useEffect(() => { - updatePositioning(side, sideOffset); - }, [side, sideOffset, updatePositioning]); - - return <>{children}; +export function renderPopoverPopup(props: PopoverPopupRenderProps): JSX.Element { + return
; } -function PopoverPopup({ className, style, children }: PopoverPopupProps): JSX.Element { - const { popupRef, triggerRef, transitionStatus, placement, popupId } = usePopoverContext(); - const triggerElement = triggerRef.current; +const PopoverPopup: ConnectedComponent = toContextComponent( + usePopoverPopupProps, + renderPopoverPopup, + 'Popover.Popup', +); - // 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]); +// ============================================================================ +// EXPORTS +// ============================================================================ - return ( -
} - id={popupId ?? undefined} - className={className} - 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} -
- ); -} - -// eslint-disable-next-line react-refresh/only-export-components -export const Popover: { +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; -} = { - Root: PopoverRoot, - Trigger: PopoverTrigger, - Positioner: PopoverPositioner, - Popup: PopoverPopup, }; export default Popover;