diff --git a/examples/react-demo/src/App.tsx b/examples/react-demo/src/App.tsx index 12b7542d..23c503a1 100644 --- a/examples/react-demo/src/App.tsx +++ b/examples/react-demo/src/App.tsx @@ -18,11 +18,11 @@ type SkinKey = (typeof skins)[number]['key']; const mediaSources = [{ key: '1', name: 'Mux 1', - value: 'https://stream.mux.com/a4nOgmxGWg6gULfcBbAa00gXyfcwPnAFldF8RdsNyk8M.m3u8' + value: 'https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ.m3u8' }, { key: '2', name: 'Mux 2', - value: 'https://stream.mux.com/fXNzVtmtWuyz00xnSrJg4OJH6PyNo6D02UzmgeKGkP5YQ.m3u8' + value: 'https://stream.mux.com/a4nOgmxGWg6gULfcBbAa00gXyfcwPnAFldF8RdsNyk8M.m3u8' }, { key: '3', name: 'Mux 3', @@ -97,6 +97,9 @@ export default function App(): JSX.Element { } }, [skinKey]); + const playbackId = mediaSource.match(/stream\.mux\.com\/([^./]+)/)?.[1]; + const poster = playbackId ? `https://image.mux.com/${playbackId}/thumbnail.webp` : undefined; + return ( <>
@@ -138,7 +141,7 @@ export default function App(): JSX.Element { {/* @ts-ignore -- types are incorrect */} - diff --git a/packages/react/react/src/components/MediaContainer.tsx b/packages/react/react/src/components/MediaContainer.tsx index 112f68a8..b8d74ea1 100644 --- a/packages/react/react/src/components/MediaContainer.tsx +++ b/packages/react/react/src/components/MediaContainer.tsx @@ -1,9 +1,11 @@ import type { FC, HTMLProps, PropsWithChildren, RefCallback } from 'react'; -import { useCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; import { useMediaStore } from '@vjs-10/react-media-store'; +import { useComposedRefs } from '../utils/useComposedRefs'; + /** * Hook to associate a React element as the fullscreen container for the media store. * This is equivalent to Media Chrome's useMediaFullscreenRef but for VJS-10. @@ -50,12 +52,12 @@ export const useMediaContainerRef = (): RefCallback => { * * ); */ -export const MediaContainer: FC>> = ({ children, ...props }) => { +export const MediaContainer: FC>> = forwardRef(({ children, ...props }, ref) => { const containerRef = useMediaContainerRef(); - + const composedRef = useComposedRefs(ref, containerRef); return ( -
+
{children}
); -}; +}); diff --git a/packages/react/react/src/components/Popover.tsx b/packages/react/react/src/components/Popover.tsx index 6272f20b..2ba540d2 100644 --- a/packages/react/react/src/components/Popover.tsx +++ b/packages/react/react/src/components/Popover.tsx @@ -6,6 +6,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { autoUpdate, flip, + FloatingPortal, offset, shift, useDismiss, @@ -49,6 +50,11 @@ interface PopoverPopupProps { children: ReactNode; } +interface PopoverPortalProps { + children: ReactNode; + container?: HTMLElement | ShadowRoot | React.MutableRefObject | null; +} + const PopoverContext = createContext(null); function usePopoverContext(): PopoverContextType { @@ -142,17 +148,23 @@ function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element { ); } +function PopoverPortal({ children, container }: PopoverPortalProps): JSX.Element { + return {children}; +} + // Export compound component export const Popover: { Root: typeof PopoverRoot; 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/react/src/components/Tooltip.tsx b/packages/react/react/src/components/Tooltip.tsx new file mode 100644 index 00000000..25a2c243 --- /dev/null +++ b/packages/react/react/src/components/Tooltip.tsx @@ -0,0 +1,249 @@ +import type { Placement } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; + +import { + arrow, + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; + +interface UpdatePositioningProps { + side: Placement; + sideOffset: number; + collisionPadding: number; +} + +interface TooltipContextType { + open: boolean; + setOpen: (open: boolean) => void; + refs: ReturnType['refs']; + floatingStyles: ReturnType['floatingStyles']; + getReferenceProps: ReturnType['getReferenceProps']; + getFloatingProps: ReturnType['getFloatingProps']; + context: ReturnType['context']; + updatePositioning: (props: UpdatePositioningProps) => void; + arrowRef: React.MutableRefObject; +} + +interface TooltipRootProps { + delay?: number; + closeDelay?: number; + children: ReactNode; +} + +interface TooltipTriggerProps { + children: ReactNode; +} + +interface TooltipPositionerProps { + side?: Placement; + sideOffset?: number; + collisionPadding?: number; + children: ReactNode; +} + +interface TooltipPopupProps { + className?: string; + children: ReactNode; +} + +interface TooltipArrowProps { + className?: string; + children: ReactNode; +} + +interface TooltipPortalProps { + children: ReactNode; + container?: HTMLElement | ShadowRoot | React.MutableRefObject | null; +} + +const TooltipContext = createContext(null); + +interface TooltipPositionerContextType { + side: Placement; +} + +const TooltipPositionerContext = createContext(null); + +function useTooltipContext(): TooltipContextType { + const context = useContext(TooltipContext); + if (!context) { + throw new Error('Tooltip components must be used within TooltipRoot'); + } + 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 = 600, closeDelay = 0, 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 { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement, + middleware: [ + offset(sideOffset), + flip(), + shift({ padding: collisionPadding }), + arrow({ + element: arrowRef, + }), + ], + whileElementsMounted: autoUpdate, + }); + + const hover = useHover(context, { + delay: { + open: delay, + close: closeDelay, + }, + }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'tooltip' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const updatePositioning = ({ side, sideOffset, collisionPadding }: UpdatePositioningProps) => { + setPlacement(side); + setSideOffset(sideOffset); + setCollisionPadding(collisionPadding); + }; + + const value: TooltipContextType = { + open, + setOpen, + refs, + floatingStyles, + getReferenceProps, + getFloatingProps, + context, + updatePositioning, + arrowRef, + }; + + return {children}; +} + +function TooltipTrigger({ children }: TooltipTriggerProps): JSX.Element { + const { refs, getReferenceProps } = useTooltipContext(); + + return React.cloneElement(React.Children.only(children) as JSX.Element, { + ref: refs.setReference, + ...getReferenceProps(), + }); +} + +function TooltipPositioner({ side = 'top', sideOffset = 0, collisionPadding = 0, children }: TooltipPositionerProps): JSX.Element | null { + const { open, refs, floatingStyles, updatePositioning } = useTooltipContext(); + + // Update positioning when props change + useEffect(() => { + updatePositioning({ side, sideOffset, collisionPadding }); + }, [side, sideOffset, collisionPadding, updatePositioning]); + + if (!open) { + return null; + } + + const positionerContextValue: TooltipPositionerContextType = { + side, + }; + + return ( + +
+ {children} +
+
+ ); +} + +function TooltipPopup({ className = '', children }: TooltipPopupProps): JSX.Element { + const { getFloatingProps } = useTooltipContext(); + const { refs } = useTooltipContext(); + const triggerElement = refs.reference.current as HTMLElement | null; + + // Copy data attributes from trigger element + const dataAttributes = triggerElement + ? Object.fromEntries( + Array.from(triggerElement.attributes) + .filter((attr) => attr.name.startsWith('data-')) + .map((attr) => [attr.name, attr.value]) + ) + : {}; + + return ( +
+ {children} +
+ ); +} + +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, container }: TooltipPortalProps): JSX.Element { + return {children}; +} + +// Export compound component +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/react/src/skins/default/MediaSkinDefault.tsx b/packages/react/react/src/skins/default/MediaSkinDefault.tsx index 1a3ca851..099e6275 100644 --- a/packages/react/react/src/skins/default/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/default/MediaSkinDefault.tsx @@ -1,4 +1,7 @@ +import styles from './styles'; + import type { PropsWithChildren } from 'react'; +import { useRef } from 'react'; import { FullscreenEnterIcon, @@ -15,29 +18,44 @@ import { DurationDisplay } from '../../components/DurationDisplay'; import { FullscreenButton } from '../../components/FullscreenButton'; import { MediaContainer } from '../../components/MediaContainer'; import MuteButton from '../../components/MuteButton'; -import { Popover } from '../../components/Popover'; import PlayButton from '../../components/PlayButton'; +import { Popover } from '../../components/Popover'; import { TimeRange } from '../../components/TimeRange'; +import { Tooltip } from '../../components/Tooltip'; import { VolumeRange } from '../../components/VolumeRange'; -import styles from './styles'; type SkinProps = PropsWithChildren<{ className?: string; }>; export default function MediaSkinDefault({ children, className = '' }: SkinProps): JSX.Element { + const containerRef = useRef(null); + const portalRef = useRef(null); + return ( - + {children} {/* Background gradient to help with controls contrast. */}