From ed43d52f8fa347acb9dd62f8458a8324e3506fd1 Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 00:40:03 -0800 Subject: [PATCH] feat(react): add popover component (#653) --- packages/react/src/index.ts | 1 + packages/react/src/ui/popover/index.parts.ts | 5 + packages/react/src/ui/popover/index.ts | 1 + .../react/src/ui/popover/popover-arrow.tsx | 38 ++++++ .../react/src/ui/popover/popover-context.tsx | 23 ++++ .../react/src/ui/popover/popover-popup.tsx | 126 ++++++++++++++++++ .../react/src/ui/popover/popover-root.tsx | 101 ++++++++++++++ .../react/src/ui/popover/popover-trigger.tsx | 56 ++++++++ packages/react/src/utils/use-latest-ref.ts | 19 +++ packages/react/src/utils/use-safe-id.ts | 18 +++ 10 files changed, 388 insertions(+) create mode 100644 packages/react/src/ui/popover/index.parts.ts create mode 100644 packages/react/src/ui/popover/index.ts create mode 100644 packages/react/src/ui/popover/popover-arrow.tsx create mode 100644 packages/react/src/ui/popover/popover-context.tsx create mode 100644 packages/react/src/ui/popover/popover-popup.tsx create mode 100644 packages/react/src/ui/popover/popover-root.tsx create mode 100644 packages/react/src/ui/popover/popover-trigger.tsx create mode 100644 packages/react/src/utils/use-latest-ref.ts create mode 100644 packages/react/src/utils/use-safe-id.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a986fe60..f71b2aed 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -38,6 +38,7 @@ export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; +export { Popover } from './ui/popover'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; diff --git a/packages/react/src/ui/popover/index.parts.ts b/packages/react/src/ui/popover/index.parts.ts new file mode 100644 index 00000000..139fa7e1 --- /dev/null +++ b/packages/react/src/ui/popover/index.parts.ts @@ -0,0 +1,5 @@ +export { PopoverArrow as Arrow, type PopoverArrowProps as ArrowProps } from './popover-arrow'; +export { type PopoverContextValue, usePopoverContext } from './popover-context'; +export { PopoverPopup as Popup, type PopoverPopupProps as PopupProps } from './popover-popup'; +export { PopoverRoot as Root, type PopoverRootProps as RootProps } from './popover-root'; +export { PopoverTrigger as Trigger, type PopoverTriggerProps as TriggerProps } from './popover-trigger'; diff --git a/packages/react/src/ui/popover/index.ts b/packages/react/src/ui/popover/index.ts new file mode 100644 index 00000000..9d2d8d70 --- /dev/null +++ b/packages/react/src/ui/popover/index.ts @@ -0,0 +1 @@ +export * as Popover from './index.parts'; diff --git a/packages/react/src/ui/popover/popover-arrow.tsx b/packages/react/src/ui/popover/popover-arrow.tsx new file mode 100644 index 00000000..411fb054 --- /dev/null +++ b/packages/react/src/ui/popover/popover-arrow.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { PopoverDataAttrs, type PopoverState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { usePopoverContext } from './popover-context'; + +export interface PopoverArrowProps extends UIComponentProps<'div', PopoverState> {} + +export const PopoverArrow = forwardRef(function PopoverArrow( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { state } = usePopoverContext(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: PopoverDataAttrs, + ref: forwardedRef, + props: [ + { + 'aria-hidden': 'true' as const, + }, + elementProps, + ], + } + ); +}); + +export namespace PopoverArrow { + export type Props = PopoverArrowProps; + export type State = PopoverState; +} diff --git a/packages/react/src/ui/popover/popover-context.tsx b/packages/react/src/ui/popover/popover-context.tsx new file mode 100644 index 00000000..cd3dd18c --- /dev/null +++ b/packages/react/src/ui/popover/popover-context.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { PopoverCore } from '@videojs/core'; +import type { PopoverHandle } from '@videojs/core/dom'; +import { createContext, useContext } from 'react'; + +export interface PopoverContextValue { + core: PopoverCore; + popover: PopoverHandle; + state: PopoverCore.State; + anchorName: string; + popupId: string; +} + +const PopoverContext = createContext(null); + +export const PopoverContextProvider = PopoverContext.Provider; + +export function usePopoverContext(): PopoverContextValue { + const ctx = useContext(PopoverContext); + if (!ctx) throw new Error('Popover compound components must be used within a Popover.Root'); + return ctx; +} diff --git a/packages/react/src/ui/popover/popover-popup.tsx b/packages/react/src/ui/popover/popover-popup.tsx new file mode 100644 index 00000000..b23d9fe6 --- /dev/null +++ b/packages/react/src/ui/popover/popover-popup.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { PopoverDataAttrs, type PopoverState } 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 { usePopoverContext } from './popover-context'; + +export interface PopoverPopupProps extends UIComponentProps<'div', PopoverState> {} + +const POPOVER_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 }; + +export const PopoverPopup = forwardRef(function PopoverPopup( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { core, popover, state, anchorName, popupId } = usePopoverContext(); + const internalRef = useRef(null); + + const popupRef = useCallback( + (el: HTMLDivElement | null) => { + popover.setPopupElement(el); + }, + [popover] + ); + + 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. + // getAnchorPositionStyle returns camelCase keys, directly compatible + // with React's style prop. + const anchorStyle = useMemo(() => { + if (!supportsAnchorPositioning()) return null; + return getAnchorPositionStyle(anchorName, posOpts) 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 = popover.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); + + setManualStyle( + getAnchorPositionStyle(anchorName, posOpts, triggerRect, popupRect, boundaryRect, offsets) as CSSProperties + ); + } + + measure(); + + // Recompute on scroll/resize so the popover 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, popover]); + + // Anchor path uses computed styles; manual path uses measured styles; + // fallback resets UA [popover] defaults until positioning is computed. + const positioningStyle = anchorStyle ?? manualStyle ?? POPOVER_RESET; + + // --- Visibility --- + + if (!state.open) { + return null; + } + + // Remap DOM focus events to React synthetic event names. + const { onFocusOut, ...restPopupProps } = popover.popupProps; + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: PopoverDataAttrs, + ref: composedRef, + props: [ + { + id: popupId, + style: positioningStyle, + ...core.getPopupAttrs(state), + }, + { ...restPopupProps, onBlur: onFocusOut }, + elementProps, + ], + } + ); +}); + +export namespace PopoverPopup { + export type Props = PopoverPopupProps; + export type State = PopoverState; +} diff --git a/packages/react/src/ui/popover/popover-root.tsx b/packages/react/src/ui/popover/popover-root.tsx new file mode 100644 index 00000000..10a4ac33 --- /dev/null +++ b/packages/react/src/ui/popover/popover-root.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { type PopoverProps as CorePopoverProps, PopoverCore } from '@videojs/core'; +import { createPopover, createTransitionHandler, type PopoverChangeDetails } 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 { PopoverContextProvider } from './popover-context'; + +export interface PopoverRootProps extends CorePopoverProps { + /** Called when the popover open state changes (fires immediately, before animations). */ + onOpenChange?: (open: boolean, details: PopoverChangeDetails) => void; + /** Called after open/close animations complete. */ + onOpenChangeComplete?: (open: boolean) => void; + children?: ReactNode; +} + +export function PopoverRoot({ + open: controlledOpen, + defaultOpen = PopoverCore.defaultProps.defaultOpen, + onOpenChange: onOpenChangeProp, + onOpenChangeComplete: onOpenChangeCompleteProp, + openOnHover = PopoverCore.defaultProps.openOnHover, + delay = PopoverCore.defaultProps.delay, + closeDelay = PopoverCore.defaultProps.closeDelay, + children, + ...coreProps +}: PopoverRootProps): ReactNode { + const [core] = useState(() => new PopoverCore(coreProps)); + core.setProps(coreProps); + + const isControlled = controlledOpen !== undefined; + + // Keep refs that always point to the latest values so the + // createPopover closure never reads stale props. + const onOpenChangeRef = useLatestRef(onOpenChangeProp); + const onOpenChangeCompleteRef = useLatestRef(onOpenChangeCompleteProp); + const closeOnEscapeRef = useLatestRef(coreProps.closeOnEscape); + const closeOnOutsideClickRef = useLatestRef(coreProps.closeOnOutsideClick); + const openOnHoverRef = useLatestRef(openOnHover); + const delayRef = useLatestRef(delay); + const closeDelayRef = useLatestRef(closeDelay); + + const [popover] = useState(() => { + const instance = createPopover({ + transition: createTransitionHandler(), + onOpenChange: (nextOpen: boolean, details: PopoverChangeDetails) => { + onOpenChangeRef.current?.(nextOpen, details); + }, + onOpenChangeComplete: (nextOpen: boolean) => { + onOpenChangeCompleteRef.current?.(nextOpen); + }, + closeOnEscape: () => closeOnEscapeRef.current ?? PopoverCore.defaultProps.closeOnEscape, + closeOnOutsideClick: () => closeOnOutsideClickRef.current ?? PopoverCore.defaultProps.closeOnOutsideClick, + openOnHover: () => openOnHoverRef.current, + delay: () => delayRef.current, + closeDelay: () => closeDelayRef.current, + }); + + // Apply defaultOpen on creation (uncontrolled only) + if (!isControlled && defaultOpen) { + instance.open('click'); + } + + return instance; + }); + + const anchorName = useSafeId(); + const popupId = useSafeId('popup-'); + + // Sync controlled open prop -> internal interaction state. + useEffect(() => { + if (controlledOpen === undefined) return; + + const { active: interactionOpen } = popover.interaction.current; + if (controlledOpen === interactionOpen) return; + + if (controlledOpen) { + popover.open('click'); + } else { + popover.close('click'); + } + }, [controlledOpen, popover]); + + // Cleanup on unmount + useEffect(() => () => popover.destroy(), [popover]); + + const interaction = useSnapshot(popover.interaction); + const state = core.getState(interaction); + + return ( + {children} + ); +} + +export namespace PopoverRoot { + export type Props = PopoverRootProps; +} diff --git a/packages/react/src/ui/popover/popover-trigger.tsx b/packages/react/src/ui/popover/popover-trigger.tsx new file mode 100644 index 00000000..d8a1924f --- /dev/null +++ b/packages/react/src/ui/popover/popover-trigger.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { PopoverDataAttrs, type PopoverState } from '@videojs/core'; +import { getAnchorNameStyle } from '@videojs/core/dom'; +import { forwardRef, useCallback, useMemo } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { usePopoverContext } from './popover-context'; + +export interface PopoverTriggerProps extends UIComponentProps<'button', PopoverState> {} + +export const PopoverTrigger = forwardRef(function PopoverTrigger( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { core, popover, state, anchorName, popupId } = usePopoverContext(); + + const triggerRef = useCallback( + (el: HTMLButtonElement | null) => { + popover.setTriggerElement(el); + }, + [popover] + ); + + const anchorStyle = useMemo(() => getAnchorNameStyle(anchorName), [anchorName]); + + // Remap DOM focus events to React synthetic event names. + // createPopover() uses onFocusIn/onFocusOut (matching DOM focusin/focusout), + // but React maps those to onFocus/onBlur. + const { onFocusIn, onFocusOut, ...restTriggerProps } = popover.triggerProps; + + return renderElement( + 'button', + { render, className, style }, + { + state, + stateAttrMap: PopoverDataAttrs, + ref: [forwardedRef, triggerRef], + props: [ + { + type: 'button' as const, + style: anchorStyle, + ...core.getTriggerAttrs(state, popupId), + }, + { ...restTriggerProps, onFocus: onFocusIn, onBlur: onFocusOut }, + elementProps, + ], + } + ); +}); + +export namespace PopoverTrigger { + export type Props = PopoverTriggerProps; + export type State = PopoverState; +} diff --git a/packages/react/src/utils/use-latest-ref.ts b/packages/react/src/utils/use-latest-ref.ts new file mode 100644 index 00000000..175e305c --- /dev/null +++ b/packages/react/src/utils/use-latest-ref.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useRef } from 'react'; + +/** + * Keep a ref that always points to the latest value. + * + * Useful for capturing callbacks or derived values inside closures + * that are created once (e.g. factory callbacks) without stale reads. + */ +export function useLatestRef(value: Value): Readonly<{ current: Value }> { + const ref = useRef(value); + ref.current = value; + return ref; +} + +export namespace useLatestRef { + export type Result = Readonly<{ current: Value }>; +} diff --git a/packages/react/src/utils/use-safe-id.ts b/packages/react/src/utils/use-safe-id.ts new file mode 100644 index 00000000..5e91b505 --- /dev/null +++ b/packages/react/src/utils/use-safe-id.ts @@ -0,0 +1,18 @@ +'use client'; + +import { useId } from 'react'; + +const UNSAFE_CHARS = /[^a-zA-Z0-9_-]/g; + +/** + * Generate a CSS-safe identifier from React's `useId()`. + * + * `useId()` returns values like `:r0:` which contain colons — invalid + * in CSS `` tokens (used by `anchor-name` / `position-anchor`). + * This hook strips non-alphanumeric/underscore/hyphen characters and + * optionally prepends a prefix. + */ +export function useSafeId(prefix?: string): string { + const raw = useId().replace(UNSAFE_CHARS, ''); + return prefix ? `${prefix}${raw}` : raw; +}