feat(react): add popover component (#653)

This commit is contained in:
rahim
2026-02-28 00:40:03 -08:00
committed by GitHub
parent bebd03ff2b
commit ed43d52f8f
10 changed files with 388 additions and 0 deletions
+1
View File
@@ -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';
@@ -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';
+1
View File
@@ -0,0 +1 @@
export * as Popover from './index.parts';
@@ -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<HTMLDivElement, PopoverArrowProps>(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;
}
@@ -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<PopoverContextValue | null>(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;
}
@@ -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<HTMLDivElement, PopoverPopupProps>(function PopoverPopup(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, popover, state, anchorName, popupId } = usePopoverContext();
const internalRef = useRef<HTMLDivElement>(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<CSSProperties | null>(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;
}
@@ -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 (
<PopoverContextProvider value={{ core, popover, state, anchorName, popupId }}>{children}</PopoverContextProvider>
);
}
export namespace PopoverRoot {
export type Props = PopoverRootProps;
}
@@ -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<HTMLButtonElement, PopoverTriggerProps>(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;
}
@@ -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: Value): Readonly<{ current: Value }> {
const ref = useRef(value);
ref.current = value;
return ref;
}
export namespace useLatestRef {
export type Result<Value> = Readonly<{ current: Value }>;
}
+18
View File
@@ -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 `<dashed-ident>` 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;
}