diff --git a/examples/react-demo/src/skins/frosted-eject/FrostedSkin.tsx b/examples/react-demo/src/skins/frosted-eject/FrostedSkin.tsx index 9a87eb4c..d148f216 100644 --- a/examples/react-demo/src/skins/frosted-eject/FrostedSkin.tsx +++ b/examples/react-demo/src/skins/frosted-eject/FrostedSkin.tsx @@ -32,14 +32,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Play - Pause - - - + + + Play + Pause + +
@@ -59,13 +57,11 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - - - - + + + + + @@ -79,18 +75,16 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - - - - - - - - - + + + + + + + + + + @@ -100,14 +94,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Enter Fullscreen - Exit Fullscreen - - - + + + Enter Fullscreen + Exit Fullscreen + +
diff --git a/examples/react-demo/src/skins/frosted-eject/frosted.css b/examples/react-demo/src/skins/frosted-eject/frosted.css index db5d4bf9..b542e9e3 100644 --- a/examples/react-demo/src/skins/frosted-eject/frosted.css +++ b/examples/react-demo/src/skins/frosted-eject/frosted.css @@ -330,8 +330,6 @@ .vjs-frosted-skin .popover-popup { margin: 0; border: none; - box-shadow: none; - background: transparent; padding: 0.75rem 0.25rem; border-radius: calc(infinity * 1px); } diff --git a/examples/react-demo/src/skins/frosted/FrostedSkin.tsx b/examples/react-demo/src/skins/frosted/FrostedSkin.tsx index 7c087350..0c4319b5 100644 --- a/examples/react-demo/src/skins/frosted/FrostedSkin.tsx +++ b/examples/react-demo/src/skins/frosted/FrostedSkin.tsx @@ -45,14 +45,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Play - Pause - - - + + + Play + Pause + +
@@ -81,18 +79,16 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - - - - - - - - - + + + + + + + + + + @@ -102,14 +98,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Enter Fullscreen - Exit Fullscreen - - - + + + Enter Fullscreen + Exit Fullscreen + +
diff --git a/examples/react-demo/src/skins/minimal-eject/MinimalSkin.tsx b/examples/react-demo/src/skins/minimal-eject/MinimalSkin.tsx index 329e027c..e60c197b 100644 --- a/examples/react-demo/src/skins/minimal-eject/MinimalSkin.tsx +++ b/examples/react-demo/src/skins/minimal-eject/MinimalSkin.tsx @@ -32,14 +32,12 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - Play - Pause - - - + + + Play + Pause + +
@@ -65,13 +63,11 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - - - - + + + + +
@@ -83,18 +79,16 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - - - - - - - - - + + + + + + + + + + @@ -104,14 +98,12 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - Enter Fullscreen - Exit Fullscreen - - - + + + Enter Fullscreen + Exit Fullscreen + +
diff --git a/packages/html/src/elements/popover.ts b/packages/html/src/elements/popover.ts index ccb848c3..5be410ab 100644 --- a/packages/html/src/elements/popover.ts +++ b/packages/html/src/elements/popover.ts @@ -1,6 +1,6 @@ import { contains, getDocument, getDocumentOrShadowRoot, safePolygon } from '@videojs/utils/dom'; -type Placement = 'top' | 'bottom' | 'left' | 'right'; +type Placement = 'top' | 'top-start' | 'top-end'; export class PopoverElement extends HTMLElement { static get observedAttributes(): string[] { @@ -18,9 +18,14 @@ export class PopoverElement extends HTMLElement { this.style.setProperty('position-anchor', `--${newValue}`); } - this.style.setProperty('top', `calc(anchor(${this.side}) - ${this.sideOffset}px)`); + const [side, alignment] = this.side.split('-'); + this.style.setProperty('top', `calc(anchor(${side}) - ${this.sideOffset}px)`); this.style.setProperty('translate', `0 -100%`); - this.style.setProperty('justify-self', 'anchor-center'); + this.style.setProperty('justify-self', alignment === 'start' + ? 'anchor-start' + : alignment === 'end' + ? 'anchor-end' + : 'anchor-center'); } connectedCallback(): void { diff --git a/packages/html/src/elements/tooltip.ts b/packages/html/src/elements/tooltip.ts index 2bafbf30..d95f521c 100644 --- a/packages/html/src/elements/tooltip.ts +++ b/packages/html/src/elements/tooltip.ts @@ -6,7 +6,7 @@ import { getInBoundsAdjustments, } from '@videojs/utils/dom'; -type Placement = 'top' | 'bottom' | 'left' | 'right'; +type Placement = 'top' | 'top-start' | 'top-end'; export class TooltipElement extends HTMLElement { static get observedAttributes(): string[] { @@ -30,14 +30,18 @@ export class TooltipElement extends HTMLElement { if (name === 'id') { this.style.setProperty('position-anchor', `--${newValue}`); } - - this.style.setProperty('top', `calc(anchor(${this.side}) - ${this.sideOffset}px)`); + const [side, alignment] = this.side.split('-'); + this.style.setProperty('top', `calc(anchor(${side}) - ${this.sideOffset}px)`); if (this.trackCursorAxis) { this.style.setProperty('translate', `-50% -100%`); } else { this.style.setProperty('translate', `0 -100%`); - this.style.setProperty('justify-self', 'anchor-center'); + this.style.setProperty('justify-self', alignment === 'start' + ? 'anchor-start' + : alignment === 'end' + ? 'anchor-end' + : 'anchor-center'); } } diff --git a/packages/html/src/media/media-container.ts b/packages/html/src/media/media-container.ts index c6dd1847..b5289f55 100644 --- a/packages/html/src/media/media-container.ts +++ b/packages/html/src/media/media-container.ts @@ -2,14 +2,10 @@ import type { Constructor, CustomElement } from '@open-wc/context-protocol'; import { ConsumerMixin } from '@open-wc/context-protocol'; -/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */ export function getTemplateHTML() { return /* html */ ` -
- -
`; } diff --git a/packages/react/package.json b/packages/react/package.json index d846ffad..e0efe23d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -55,7 +55,6 @@ "react": ">=16.8.0" }, "dependencies": { - "@floating-ui/react": "^0.27.16", "@videojs/core": "workspace:*", "@videojs/icons": "workspace:*", "@videojs/utils": "workspace:*" diff --git a/packages/react/src/components/MediaContainer.tsx b/packages/react/src/components/MediaContainer.tsx index d916f796..0b7e9bf1 100644 --- a/packages/react/src/components/MediaContainer.tsx +++ b/packages/react/src/components/MediaContainer.tsx @@ -54,8 +54,8 @@ export function useMediaContainerRef(): RefCallback { * * ); */ -export const MediaContainer: FC & { portalId?: string }>> = forwardRef( - ({ children, portalId = '@default_portal_id', ...props }, ref) => { +export const MediaContainer: FC>> = forwardRef( + ({ children, ...props }, ref) => { const containerRef = useMediaContainerRef(); const composedRef = useComposedRefs(ref, containerRef); @@ -78,11 +78,10 @@ export const MediaContainer: FC & {
{children} - {/* @TODO We need to make sure this is non-brittle longer term (CJP) */} -
); }, diff --git a/packages/react/src/components/Popover.tsx b/packages/react/src/components/Popover.tsx index 4ec11b0d..5a832423 100644 --- a/packages/react/src/components/Popover.tsx +++ b/packages/react/src/components/Popover.tsx @@ -1,22 +1,6 @@ -import type { OpenChangeReason, Placement } from '@floating-ui/react'; -import type { MutableRefObject, ReactNode } from 'react'; +import type { ReactNode } from 'react'; -import { - autoUpdate, - flip, - FloatingFocusManager, - FloatingPortal, - offset, - safePolygon, - shift, - useDismiss, - useFloating, - useFocus, - useHover, - useInteractions, - useRole, - useTransitionStatus, -} from '@floating-ui/react'; +import { contains, safePolygon } from '@videojs/utils/dom'; import { Children, @@ -26,20 +10,23 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; +type Placement = 'top' | 'top-start' | 'top-end'; + +type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted'; + interface PopoverContextType { open: boolean; setOpen: (open: boolean) => void; - openReason: OpenChangeReason | null; - refs: ReturnType['refs']; - floatingStyles: ReturnType['floatingStyles']; - getReferenceProps: ReturnType['getReferenceProps']; - getFloatingProps: ReturnType['getFloatingProps']; - context: ReturnType['context']; + popupRef: React.RefObject; + triggerRef: React.RefObject; updatePositioning: (placement: Placement, sideOffset: number) => void; - transitionStatus: ReturnType['status']; + transitionStatus: TransitionStatus; + placement: Placement; + sideOffset: number; } interface PopoverRootProps { @@ -60,16 +47,11 @@ interface PopoverPositionerProps { } interface PopoverPopupProps { + id?: string; className?: string; children: ReactNode; } -interface PopoverPortalProps { - children: ReactNode; - root?: HTMLElement | ShadowRoot | MutableRefObject | null; - rootId?: string; -} - const PopoverContext = createContext(null); function usePopoverContext(): PopoverContextType { @@ -84,125 +66,243 @@ function PopoverRoot({ openOnHover = false, delay = 0, closeDelay = 0, children const [open, setOpen] = useState(false); const [placement, setPlacement] = useState('top'); const [sideOffset, setSideOffset] = useState(5); - const [openReason, setOpenReason] = useState(null); + 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 { refs, floatingStyles, context } = useFloating({ - open, - onOpenChange: (open, _event, reason) => { - setOpen(open); - setOpenReason(reason || null); - }, - placement, - middleware: [offset(sideOffset), flip(), shift()], - whileElementsMounted: autoUpdate, - }); + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); - const { status: transitionStatus } = useTransitionStatus(context); + const setOpenState = useCallback((newOpen: boolean) => { + if (open === newOpen) return; - const hover = useHover(context, { - enabled: openOnHover, - mouseOnly: true, - move: false, - delay: { - open: delay, - close: closeDelay, - }, - handleClose: safePolygon({ blockPointerEvents: true }), - }); - const focus = useFocus(context); - const dismiss = useDismiss(context); - const role = useRole(context); + setOpen(newOpen); - const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + 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 popupId = popup.id; + if (popupId) { + popup.style.setProperty('position-anchor', `--${popupId}`); + } + + 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, - openReason, - refs, - floatingStyles, - getReferenceProps, - getFloatingProps, - context, + setOpen: setOpenState, + popupRef, + triggerRef, updatePositioning, transitionStatus, - }), [open, openReason, refs, floatingStyles, getReferenceProps, getFloatingProps, context, updatePositioning, transitionStatus]); + placement, + sideOffset, + }), [open, setOpenState, updatePositioning, transitionStatus, placement, sideOffset]); return {children}; } function PopoverTrigger({ children }: PopoverTriggerProps): JSX.Element { - const { refs, getReferenceProps, open } = usePopoverContext(); + const { triggerRef, open } = usePopoverContext(); // eslint-disable-next-line react/no-clone-element, react/no-children-only return cloneElement(Children.only(children) as JSX.Element, { - ref: refs.setReference, - ...getReferenceProps(), + ref: triggerRef, 'data-popup-open': open ? '' : undefined, }); } function PopoverPositioner({ side = 'top', sideOffset = 5, children }: PopoverPositionerProps): JSX.Element | null { - const { refs, floatingStyles, updatePositioning } = usePopoverContext(); + const { updatePositioning } = usePopoverContext(); useEffect(() => { updatePositioning(side, sideOffset); }, [side, sideOffset, updatePositioning]); - return ( -
- {children} -
- ); + return <>{children}; } -function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element { - const { getFloatingProps, context, transitionStatus } = usePopoverContext(); - const { refs, placement } = context; - const triggerElement = refs.reference.current as HTMLElement | null; +function PopoverPopup({ id, className, children }: PopoverPopupProps): JSX.Element { + const { popupRef, triggerRef, transitionStatus, placement } = usePopoverContext(); + const triggerElement = triggerRef.current; + + const popupId = useMemo(() => id ?? `popover-${Math.random().toString(36).substring(2, 9)}`, [id]); + + useEffect(() => { + if (!popupRef.current || !triggerRef.current) return; + + const popup = popupRef.current; + const trigger = triggerRef.current; + + popup.setAttribute('popover', 'manual'); + trigger.setAttribute('commandfor', popupId); + trigger.style.setProperty('anchor-name', `--${popupId}`); + }, [popupId, popupRef, triggerRef]); // Copy data attributes from trigger element - const dataAttributes = triggerElement?.attributes - ? Object.fromEntries( - Array.from(triggerElement.attributes) - .filter(attr => attr.name.startsWith('data-')) - .map(attr => [attr.name, attr.value]), - ) - : {}; + 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]); return ( - } + id={popupId} + className={className} + {...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} -
-
- ); -} - -function PopoverPortal({ children, root, rootId = '@default_portal_id' }: PopoverPortalProps): JSX.Element { - return ( - {children} - +
); } @@ -212,13 +312,11 @@ export const Popover: { Trigger: typeof PopoverTrigger; Positioner: typeof PopoverPositioner; Popup: typeof PopoverPopup; - Portal: typeof PopoverPortal; } = { Root: PopoverRoot, Trigger: PopoverTrigger, Positioner: PopoverPositioner, Popup: PopoverPopup, - Portal: PopoverPortal, }; export default Popover; diff --git a/packages/react/src/components/Tooltip.tsx b/packages/react/src/components/Tooltip.tsx index 2f2a999f..cfb53930 100644 --- a/packages/react/src/components/Tooltip.tsx +++ b/packages/react/src/components/Tooltip.tsx @@ -1,22 +1,6 @@ -import type { Placement } from '@floating-ui/react'; -import type { MutableRefObject, ReactNode, RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; -import { - arrow, - autoUpdate, - flip, - FloatingPortal, - offset, - shift, - useClientPoint, - useDismiss, - useFloating, - useFocus, - useHover, - useInteractions, - useRole, - useTransitionStatus, -} from '@floating-ui/react'; +import { contains, getBoundingClientRectWithoutTransform, getInBoundsAdjustments } from '@videojs/utils/dom'; import { Children, @@ -30,20 +14,26 @@ import { useState, } from 'react'; +type Placement = 'top' | 'top-start' | 'top-end'; + interface UpdatePositioningProps { side: Placement; sideOffset: number; collisionPadding: number; } +type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted'; + interface TooltipContextType { - arrowRef: MutableRefObject; - context: ReturnType['context']; - transitionStatus: ReturnType['status']; - getReferenceProps: ReturnType['getReferenceProps']; - getFloatingProps: ReturnType['getFloatingProps']; + popupRef: RefObject; + triggerRef: RefObject; + open: boolean; + transitionStatus: TransitionStatus; updatePositioning: (props: UpdatePositioningProps) => void; trackCursorAxis?: 'x' | 'y' | 'both' | undefined; + placement: Placement; + sideOffset: number; + collisionPadding: number; } interface TooltipRootProps { @@ -65,29 +55,13 @@ interface TooltipPositionerProps { } interface TooltipPopupProps { + id?: string; className?: string; children: ReactNode; } -interface TooltipArrowProps { - className?: string; - children: ReactNode; -} - -interface TooltipPortalProps { - children: ReactNode; - root?: HTMLElement | ShadowRoot | MutableRefObject | null; - rootId?: string; -} - const TooltipContext = createContext(null); -interface TooltipPositionerContextType { - side: Placement; -} - -const TooltipPositionerContext = createContext(null); - function useTooltipContext(): TooltipContextType { const context = useContext(TooltipContext); if (!context) { @@ -96,61 +70,16 @@ function useTooltipContext(): TooltipContextType { return context; } -function useTooltipPositionerContext(): TooltipPositionerContextType { - const context = useContext(TooltipPositionerContext); - if (!context) { - throw new Error('TooltipArrow must be used within TooltipPositioner'); - } - return context; -} - function TooltipRoot({ delay = 0, closeDelay = 0, trackCursorAxis, children }: TooltipRootProps): JSX.Element { const [open, setOpen] = useState(false); const [placement, setPlacement] = useState('top'); const [sideOffset, setSideOffset] = useState(0); const [collisionPadding, setCollisionPadding] = useState(0); - const arrowRef = useRef(null); - - const { context } = useFloating({ - open, - onOpenChange: setOpen, - placement, - middleware: [ - offset(sideOffset), - flip(), - shift({ padding: collisionPadding }), - arrow({ - element: arrowRef, - }), - ], - whileElementsMounted: autoUpdate, - }); - - const { status: transitionStatus } = useTransitionStatus(context); - - const hover = useHover(context, { - // restMs: 300, // this broke the time slider tooltip, todo: find solution! - delay: { - open: delay, - close: closeDelay, - }, - }); - const focus = useFocus(context); - const dismiss = useDismiss(context); - const role = useRole(context, { role: 'tooltip' }); - - // Use client point hook when trackCursorAxis is enabled - const clientPoint = useClientPoint(context, { - axis: trackCursorAxis || 'both', - enabled: !!trackCursorAxis, - }); - - // Combine interactions based on whether cursor tracking is enabled - const interactions = trackCursorAxis - ? [hover, focus, dismiss, role, clientPoint] - : [hover, focus, dismiss, role]; - - const { getReferenceProps, getFloatingProps } = useInteractions(interactions); + const [transitionStatus, setTransitionStatus] = useState('initial'); + const popupRef = useRef(null); + const triggerRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const pointerPositionRef = useRef({ x: 0, y: 0 }); const updatePositioning = useCallback(({ side, sideOffset, collisionPadding }: UpdatePositioningProps) => { setPlacement(side); @@ -158,27 +87,203 @@ function TooltipRoot({ delay = 0, closeDelay = 0, trackCursorAxis, children }: T setCollisionPadding(collisionPadding); }, []); - const value: TooltipContextType = useMemo(() => ({ - getReferenceProps, - getFloatingProps, - context, - updatePositioning, - arrowRef, - transitionStatus, - trackCursorAxis, - }), [getReferenceProps, getFloatingProps, context, updatePositioning, transitionStatus, trackCursorAxis]); + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + const checkCollision = useCallback(() => { + if (!popupRef.current || !triggerRef.current || !open) return; + + const mediaContainer = popupRef.current.closest('[data-media-container]') as HTMLElement | null; + if (!mediaContainer) return; + + const popupRect = getBoundingClientRectWithoutTransform(popupRef.current); + const boundsRect = getBoundingClientRectWithoutTransform(mediaContainer); + const { x } = getInBoundsAdjustments(popupRect, boundsRect, collisionPadding); + + if (x !== 0) { + if (trackCursorAxis) { + const currentLeft = Number.parseFloat(popupRef.current.style.left || '0'); + popupRef.current.style.setProperty('left', `${currentLeft + x}px`); + } else { + popupRef.current.style.setProperty('translate', `${x}px -100%`); + } + } else { + if (trackCursorAxis) { + popupRef.current.style.setProperty('translate', '-50% -100%'); + } else { + popupRef.current.style.setProperty('translate', '0 -100%'); + } + } + }, [open, collisionPadding, trackCursorAxis]); + + const updatePosition = useCallback(() => { + if (open && trackCursorAxis && popupRef.current) { + popupRef.current.style.setProperty('left', `${pointerPositionRef.current.x}px`); + checkCollision(); + } + }, [open, trackCursorAxis, checkCollision]); + + const setOpenState = useCallback( + (newOpen: boolean) => { + if (open === newOpen) return; + + setOpen(newOpen); + + if (newOpen) { + setTransitionStatus('initial'); + if (popupRef.current) { + popupRef.current.showPopover(); + } + requestAnimationFrame(() => { + setTransitionStatus('open'); + checkCollision(); + }); + } else { + setTransitionStatus('close'); + } + }, + [open, checkCollision], + ); + + 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]); + + useEffect(() => { + const trigger = triggerRef.current; + const popup = popupRef.current; + if (!trigger || !popup) return; + + const abortController = new AbortController(); + const { signal } = abortController; + + const handlePointerEnter = () => { + clearHoverTimeout(); + + hoverTimeoutRef.current = setTimeout(() => { + setOpenState(true); + }, delay); + }; + + const handlePointerLeave = () => { + clearHoverTimeout(); + + hoverTimeoutRef.current = setTimeout(() => { + setOpenState(false); + }, closeDelay); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (trackCursorAxis) { + pointerPositionRef.current = { x: event.clientX, y: event.clientY }; + if (open) { + updatePosition(); + } + } + }; + + const handleFocusIn = () => { + setOpenState(true); + }; + + const handleFocusOut = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement; + if (relatedTarget && popup && contains(popup, relatedTarget)) return; + setOpenState(false); + }; + + 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 }); + + if (trackCursorAxis) { + trigger.addEventListener('pointermove', handlePointerMove, { signal }); + } + } + + // Event listeners are automatically removed when AbortController is aborted + trigger.addEventListener('focusin', handleFocusIn, { signal }); + trigger.addEventListener('focusout', handleFocusOut, { signal }); + + return () => { + abortController.abort(); + clearHoverTimeout(); + }; + }, [delay, closeDelay, trackCursorAxis, open, setOpenState, clearHoverTimeout, updatePosition]); + + useEffect(() => { + if (!popupRef.current || !triggerRef.current) return; + + const popup = popupRef.current; + const popupId = popup.id; + if (popupId) { + popup.style.setProperty('position-anchor', `--${popupId}`); + } + + const [side, alignment] = placement.split('-'); + popup.style.setProperty('top', `calc(anchor(${side}) - ${sideOffset}px)`); + + if (trackCursorAxis) { + popup.style.setProperty('translate', `-50% -100%`); + } else { + popup.style.setProperty('translate', `0 -100%`); + popup.style.setProperty('justify-self', alignment === 'start' + ? 'anchor-start' + : alignment === 'end' + ? 'anchor-end' + : 'anchor-center'); + } + }, [placement, sideOffset, trackCursorAxis]); + + useEffect(() => { + if (!popupRef.current) return; + + const resizeObserver = new ResizeObserver(checkCollision); + resizeObserver.observe(popupRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [checkCollision]); + + const value: TooltipContextType = useMemo( + () => ({ + popupRef, + triggerRef, + open, + transitionStatus, + updatePositioning, + trackCursorAxis, + placement, + sideOffset, + collisionPadding, + }), + [open, transitionStatus, updatePositioning, trackCursorAxis, placement, sideOffset, collisionPadding], + ); return {children}; } function TooltipTrigger({ children }: TooltipTriggerProps): JSX.Element { - const { context, getReferenceProps } = useTooltipContext(); - const { refs, open } = context; + const { triggerRef, open } = useTooltipContext(); // eslint-disable-next-line react/no-clone-element, react/no-children-only return cloneElement(Children.only(children) as JSX.Element, { - ref: refs.setReference, - ...getReferenceProps(), + ref: triggerRef, 'data-popup-open': open ? '' : undefined, }); } @@ -189,51 +294,48 @@ function TooltipPositioner({ collisionPadding = 0, children, }: TooltipPositionerProps): JSX.Element | null { - const { context, updatePositioning, trackCursorAxis } = useTooltipContext(); - const { refs, floatingStyles } = context; + const { updatePositioning } = useTooltipContext(); - // Update positioning when props change useEffect(() => { updatePositioning({ side, sideOffset, collisionPadding }); }, [side, sideOffset, collisionPadding, updatePositioning]); - const positionerContextValue: TooltipPositionerContextType = useMemo(() => ({ - side, - }), [side]); - - return ( - -
- {children} -
-
- ); + return <>{children}; } -function TooltipPopup({ className = '', children }: TooltipPopupProps): JSX.Element | null { - const { context, getFloatingProps, transitionStatus } = useTooltipContext(); - const { refs, placement } = context; - const triggerElement = refs.reference.current as HTMLElement | null; +function TooltipPopup({ id, className = '', children }: TooltipPopupProps): JSX.Element | null { + const { popupRef, triggerRef, transitionStatus, placement } = useTooltipContext(); + const triggerElement = triggerRef.current; + + const popupId = useMemo(() => id ?? `tooltip-${Math.random().toString(36).substring(2, 9)}`, [id]); + + useEffect(() => { + if (!popupRef.current || !triggerRef.current) return; + + const popup = popupRef.current; + const trigger = triggerRef.current; + + popup.setAttribute('popover', 'manual'); + trigger.setAttribute('commandfor', popupId); + trigger.style.setProperty('anchor-name', `--${popupId}`); + }, [popupId, popupRef, triggerRef]); // Copy data attributes from trigger element - const dataAttributes = triggerElement?.attributes - ? Object.fromEntries( - Array.from(triggerElement.attributes) - .filter(attr => attr.name.startsWith('data-')) - .map(attr => [attr.name, attr.value]), - ) - : {}; + 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]); return (
} + id={popupId} className={className} - {...getFloatingProps()} + role="tooltip" {...dataAttributes} data-side={placement} data-starting-style={transitionStatus === 'initial' ? '' : undefined} @@ -246,53 +348,17 @@ function TooltipPopup({ className = '', children }: TooltipPopupProps): JSX.Elem ); } -function TooltipArrow({ className = '', children }: TooltipArrowProps): JSX.Element { - const { arrowRef, context } = useTooltipContext(); - const { side } = useTooltipPositionerContext(); - - // Get arrow positioning data from floating-ui - const { x: arrowX, y: arrowY } = context.middlewareData.arrow || { x: 0, y: 0 }; - - return ( -
} - className={className} - aria-hidden="true" - data-side={side} - style={{ - left: arrowX != null ? `${arrowX}px` : undefined, - top: arrowY != null ? `${arrowY}px` : undefined, - }} - > - {children} -
- ); -} - -function TooltipPortal({ children, root, rootId = '@default_portal_id' }: TooltipPortalProps): JSX.Element { - return ( - - {children} - - ); -} - -// Export compound component // eslint-disable-next-line react-refresh/only-export-components export const Tooltip: { Root: typeof TooltipRoot; Trigger: typeof TooltipTrigger; Positioner: typeof TooltipPositioner; Popup: typeof TooltipPopup; - Arrow: typeof TooltipArrow; - Portal: typeof TooltipPortal; } = { Root: TooltipRoot, Trigger: TooltipTrigger, Positioner: TooltipPositioner, Popup: TooltipPopup, - Arrow: TooltipArrow, - Portal: TooltipPortal, }; export default Tooltip; diff --git a/packages/react/src/skins/frosted/FrostedSkin.tsx b/packages/react/src/skins/frosted/FrostedSkin.tsx index e4ec770a..db651a27 100644 --- a/packages/react/src/skins/frosted/FrostedSkin.tsx +++ b/packages/react/src/skins/frosted/FrostedSkin.tsx @@ -41,14 +41,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Play - Pause - - - + + + Play + Pause + +
@@ -68,13 +66,11 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - - - - + + + + + @@ -88,18 +84,16 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - - - - - - - - - + + + + + + + + + + @@ -109,14 +103,12 @@ export default function FrostedSkin({ children, className = '' }: SkinProps): JS - - - - Enter Fullscreen - Exit Fullscreen - - - + + + Enter Fullscreen + Exit Fullscreen + +
diff --git a/packages/react/src/skins/minimal/MinimalSkin.tsx b/packages/react/src/skins/minimal/MinimalSkin.tsx index add81185..f65cbc58 100644 --- a/packages/react/src/skins/minimal/MinimalSkin.tsx +++ b/packages/react/src/skins/minimal/MinimalSkin.tsx @@ -41,14 +41,12 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - Play - Pause - - - + + + Play + Pause + +
@@ -74,13 +72,11 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - - - - + + + + +
@@ -92,18 +88,16 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - - - - - - - - - + + + + + + + + + + @@ -113,14 +107,12 @@ export default function MinimalSkin({ children, className = '' }: SkinProps): JS - - - - Enter Fullscreen - Exit Fullscreen - - - + + + Enter Fullscreen + Exit Fullscreen + +
diff --git a/packages/react/src/skins/minimal/styles.ts b/packages/react/src/skins/minimal/styles.ts index 3408ec02..870cdc8b 100644 --- a/packages/react/src/skins/minimal/styles.ts +++ b/packages/react/src/skins/minimal/styles.ts @@ -131,7 +131,7 @@ const styles: MinimalSkinStyles = { 'vjs:data-starting-style:scale-0 vjs:data-starting-style:opacity-0 vjs:data-starting-style:blur-sm', 'vjs:data-ending-style:scale-0 vjs:data-ending-style:opacity-0 vjs:data-ending-style:blur-sm', ), - PopoverPopup: cn('vjs:py-2'), + PopoverPopup: cn('vjs:py-2 vjs:bg-transparent'), TooltipPopup: cn( 'vjs:whitespace-nowrap vjs:flex vjs:flex-col vjs:rounded vjs:text-white vjs:text-xs vjs:@7xl/root:text-sm vjs:px-2 vjs:py-1 vjs:bg-white/20 vjs:backdrop-blur-3xl vjs:backdrop-saturate-150 vjs:backdrop-brightness-90 vjs:shadow-md vjs:shadow-black/5', ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0a48c51..2d961ab9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,9 +226,6 @@ importers: packages/react: dependencies: - '@floating-ui/react': - specifier: ^0.27.16 - version: 0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@videojs/core': specifier: workspace:* version: link:../core @@ -1056,12 +1053,6 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.27.16': - resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} @@ -7398,14 +7389,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@floating-ui/react@0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.10 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tabbable: 6.2.0 - '@floating-ui/utils@0.2.10': {} '@humanfs/core@0.19.1': {}