mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add popover component (#653)
This commit is contained in:
@@ -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';
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user