fix: use popover core in react popover (#208)

This commit is contained in:
Wesley Luyten
2025-11-25 17:27:18 -06:00
committed by GitHub
parent ec3964d560
commit 99fef78f63
3 changed files with 194 additions and 292 deletions
+16 -13
View File
@@ -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<typeof setTimeout> | null = null;
#state = map<PopoverState>({
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;
}
+2 -3
View File
@@ -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<ReturnType<CorePopover['getState']>>;
export type PopoverState = Prettify<ReturnType<CorePopover['getState']>>;
export const getPopoverState: StateHook<Popover, PopoverState> = (element, _mediaStore) => {
const coreState = getCoreState(CorePopover, getPropsFromAttrs(element));
+176 -276
View File
@@ -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<HTMLElement | null>;
triggerRef: React.RefObject<HTMLElement | null>;
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<Placement>('top');
const [sideOffset, setSideOffset] = useState(5);
const uniqueId = useId();
const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»');
const coreState = useCore<CorePopover>(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<PopoverRootProps, typeof renderPopoverRoot> = toConnectedComponent(
usePopoverRootState,
usePopoverRootProps,
renderPopoverRoot,
'Popover.Root',
);
// ============================================================================
// TRIGGER COMPONENT
// ============================================================================
interface PopoverTriggerProps {
children: ReactNode;
}
export function usePopoverTriggerProps(
props: PopoverTriggerProps,
context: PopoverState,
): { child: JSX.Element; triggerProps: Record<string, any> } {
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<string, any> }): JSX.Element {
// eslint-disable-next-line react/no-clone-element
return cloneElement(props.child, props.triggerProps);
}
const PopoverTrigger: ConnectedComponent<PopoverTriggerProps, typeof renderPopoverTrigger> = 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<PopoverPositionerProps, typeof renderPopoverPositioner>
= toContextComponent(usePopoverPositionerProps, renderPopoverPositioner, 'Popover.Positioner');
// ============================================================================
// POPUP COMPONENT
// ============================================================================
interface PopoverPopupProps {
id?: string;
className?: string;
@@ -55,269 +156,68 @@ interface PopoverPopupProps {
children: ReactNode;
}
const PopoverContext = createContext<PopoverContextType | null>(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<Placement>('top');
const [sideOffset, setSideOffset] = useState(5);
const [transitionStatus, setTransitionStatus] = useState<TransitionStatus>('initial');
const popupRef = useRef<HTMLElement | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
}
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 <div {...props} />;
}
function PopoverPopup({ className, style, children }: PopoverPopupProps): JSX.Element {
const { popupRef, triggerRef, transitionStatus, placement, popupId } = usePopoverContext();
const triggerElement = triggerRef.current;
const PopoverPopup: ConnectedComponent<PopoverPopupProps, typeof renderPopoverPopup> = 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 (
<div
ref={popupRef as React.RefObject<HTMLDivElement>}
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}
</div>
);
}
// 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;