feat: add React tooltip component (#35)

This commit is contained in:
Wesley Luyten
2025-10-07 12:31:26 -05:00
committed by GitHub
parent cfac2fb18a
commit c4a3f1b2f6
8 changed files with 449 additions and 37 deletions
+6 -3
View File
@@ -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 (
<>
<header className="fixed top-0 inset-x-0 bg-white dark:bg-stone-800 shadow shadow-black/10 after:h-px after:absolute after:inset-x-0 after:top-full after:bg-black/5">
@@ -138,7 +141,7 @@ export default function App(): JSX.Element {
<MediaProvider key={key}>
<Skin className={skinClassName}>
{/* @ts-ignore -- types are incorrect */}
<Video src={mediaSource} />
<Video src={mediaSource} poster={poster} />
</Skin>
</MediaProvider>
</div>
@@ -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<HTMLElement | null> => {
* </MediaContainer>
* );
*/
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = ({ children, ...props }) => {
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = forwardRef(({ children, ...props }, ref) => {
const containerRef = useMediaContainerRef();
const composedRef = useComposedRefs(ref, containerRef);
return (
<div ref={containerRef} {...props}>
<div ref={composedRef} {...props}>
{children}
</div>
);
};
});
@@ -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<HTMLElement | ShadowRoot | null> | null;
}
const PopoverContext = createContext<PopoverContextType | null>(null);
function usePopoverContext(): PopoverContextType {
@@ -142,17 +148,23 @@ function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element {
);
}
function PopoverPortal({ children, container }: PopoverPortalProps): JSX.Element {
return <FloatingPortal root={container as HTMLElement}>{children}</FloatingPortal>;
}
// 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;
@@ -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<typeof useFloating>['refs'];
floatingStyles: ReturnType<typeof useFloating>['floatingStyles'];
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps'];
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps'];
context: ReturnType<typeof useFloating>['context'];
updatePositioning: (props: UpdatePositioningProps) => void;
arrowRef: React.MutableRefObject<HTMLElement | null>;
}
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<HTMLElement | ShadowRoot | null> | null;
}
const TooltipContext = createContext<TooltipContextType | null>(null);
interface TooltipPositionerContextType {
side: Placement;
}
const TooltipPositionerContext = createContext<TooltipPositionerContextType | null>(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<Placement>('top');
const [sideOffset, setSideOffset] = useState(0);
const [collisionPadding, setCollisionPadding] = useState(0);
const arrowRef = useRef<HTMLElement | null>(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 <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
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 (
<TooltipPositionerContext.Provider value={positionerContextValue}>
<div ref={refs.setFloating} style={floatingStyles}>
{children}
</div>
</TooltipPositionerContext.Provider>
);
}
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 (
<div className={className} {...getFloatingProps()} {...dataAttributes}>
{children}
</div>
);
}
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 (
<div
ref={arrowRef as React.RefObject<HTMLDivElement>}
className={className}
aria-hidden="true"
data-side={side}
style={{
left: arrowX != null ? `${arrowX}px` : undefined,
top: arrowY != null ? `${arrowY}px` : undefined,
}}
>
{children}
</div>
);
}
function TooltipPortal({ children, container }: TooltipPortalProps): JSX.Element {
return <FloatingPortal root={container as HTMLElement}>{children}</FloatingPortal>;
}
// 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;
@@ -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<HTMLDivElement>(null);
const portalRef = useRef<HTMLDivElement>(null);
return (
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
<MediaContainer ref={containerRef} className={`${styles.MediaContainer} ${className}`}>
{children}
{/* Background gradient to help with controls contrast. */}
<div className={styles.Overlay} aria-hidden="true" />
<div className={styles.Controls} data-testid="media-controls">
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={styles.PlayIcon}></PlayIcon>
<PauseIcon className={styles.PauseIcon}></PauseIcon>
</PlayButton>
<Tooltip.Root delay={600} closeDelay={0}>
<Tooltip.Trigger>
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
<PlayIcon className={styles.PlayIcon}></PlayIcon>
<PauseIcon className={styles.PauseIcon}></PauseIcon>
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Portal container={portalRef.current}>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className={`${styles.TooltipPopup} ${styles.PlayTooltipPopup}`}>
<span className={styles.PlayTooltip}>Play</span>
<span className={styles.PauseTooltip}>Pause</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
<div className={styles.TimeControls}>
<CurrentTimeDisplay
@@ -65,23 +83,58 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
</Popover.Trigger>
<Popover.Positioner side="top" sideOffset={8}>
<Popover.Popup className={styles.PopoverPopup}>
<VolumeRange.Root className={styles.SliderRoot} orientation="vertical">
<VolumeRange.Track className={styles.SliderTrack}>
<VolumeRange.Progress className={styles.SliderProgress} />
</VolumeRange.Track>
<VolumeRange.Thumb className={styles.SliderThumb} />
</VolumeRange.Root>
</Popover.Popup>
</Popover.Positioner>
<Popover.Portal container={portalRef.current}>
<Popover.Positioner side="top" sideOffset={12}>
<Popover.Popup className={styles.PopoverPopup}>
<VolumeRange.Root className={styles.SliderRoot} orientation="vertical">
<VolumeRange.Track className={styles.SliderTrack}>
<VolumeRange.Progress className={styles.SliderProgress} />
</VolumeRange.Track>
<VolumeRange.Thumb className={styles.SliderThumb} />
</VolumeRange.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
<FullscreenExitIcon className={styles.FullScreenExitIcon} />
</FullscreenButton>
<Tooltip.Root delay={600} closeDelay={0}>
<Tooltip.Trigger>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
<FullscreenExitIcon className={styles.FullScreenExitIcon} />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Portal container={portalRef.current}>
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
<Tooltip.Popup className={`${styles.TooltipPopup} ${styles.FullScreenTooltipPopup}`}>
<span className={styles.FullScreenEnterTooltip}>Enter Fullscreen</span>
<span className={styles.FullScreenExitTooltip}>Exit Fullscreen</span>
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
</div>
<div ref={portalRef} className="absolute z-10" />
</MediaContainer>
);
};
}
// function ArrowSvg(props: React.ComponentProps<'svg'>) {
// return (
// <svg width="20" height="10" viewBox="0 0 20 10" fill="none" {...props}>
// <path
// d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
// className={styles.ArrowFill}
// />
// <path
// d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
// className={styles.ArrowOuterStroke}
// />
// <path
// d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
// className={styles.ArrowInnerStroke}
// />
// </svg>
// );
// }
@@ -4,7 +4,7 @@ import { cn } from "../../utils/cn";
const styles: MediaDefaultSkinStyles = {
MediaContainer: cn(
'relative @container/root group/root overflow-clip',
'media-container relative @container/root group/root overflow-clip',
// Base typography
'text-sm',
// Prevent rounded corners in fullscreen.
@@ -16,7 +16,7 @@ const styles: MediaDefaultSkinStyles = {
'[&_video]:rounded-[inherit] [&_video]:w-full [&_video]:h-auto'
),
Overlay: cn(
'opacity-0 delay-500 rounded-[inherit] absolute inset-0 pointer-events-none z-10 bg-gradient-to-t from-black/50 via-black/20 to-transparent transition-opacity backdrop-saturate-150 backdrop-brightness-90',
'opacity-0 delay-500 rounded-[inherit] absolute inset-0 pointer-events-none bg-gradient-to-t from-black/50 via-black/20 to-transparent transition-opacity backdrop-saturate-150 backdrop-brightness-90',
// Hide when playing (for now).
// FIXME: This is crude temporary logic, well improve it later I guess with a [data-show-controls] attribute or something?
'has-[+.controls_[data-paused]]:opacity-100 has-[+.controls_[data-paused]]:delay-0',
@@ -24,7 +24,7 @@ const styles: MediaDefaultSkinStyles = {
),
Controls: cn(
'controls', // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility.
'@container/controls absolute inset-x-3 bottom-3 rounded-full z-20 flex items-center p-1 ring ring-white/10 ring-inset gap-0.5 text-white text-shadow',
'@container/controls absolute inset-x-3 bottom-3 rounded-full flex items-center p-1 ring ring-white/10 ring-inset gap-0.5 text-white text-shadow',
'shadow-sm shadow-black/15',
// Background
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
@@ -69,6 +69,26 @@ const styles: MediaDefaultSkinStyles = {
),
PlayIcon: cn('play-icon'),
PauseIcon: cn('pause-icon'),
TooltipPopup: cn(
'whitespace-nowrap relative px-2 py-1 text-xs font-medium rounded-md text-white text-shadow',
// Background
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
// Shadow
// 'shadow-lg shadow-black/25',
// Border
'border border-white/10',
// Reduced transparency for users with preference
// XXX: This requires a Tailwind custom variant (see 1 below)
'reduced-transparency:bg-black/70 reduced-transparency:ring-black reduced-transparency:after:ring-white/20',
// High contrast mode
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
),
PlayTooltipPopup: cn(
'[&_.pause-tooltip]:inline [&[data-paused]_.pause-tooltip]:hidden',
'[&_.play-tooltip]:hidden [&[data-paused]_.play-tooltip]:inline'
),
PlayTooltip: cn('play-tooltip'),
PauseTooltip: cn('pause-tooltip'),
VolumeButton: cn(
'[&_svg]:opacity-0',
'[&[data-volume-level="high"]_.volume-high-icon]:opacity-100',
@@ -96,6 +116,12 @@ const styles: MediaDefaultSkinStyles = {
'group-hover/button:[&_.arrow-1]:translate-0',
'group-hover/button:[&_.arrow-2]:translate-0'
),
FullScreenTooltipPopup: cn(
'[&_.fullscreen-enter-tooltip]:inline [&[data-fullscreen]_.fullscreen-enter-tooltip]:hidden',
'[&_.fullscreen-exit-tooltip]:hidden [&[data-fullscreen]_.fullscreen-exit-tooltip]:inline'
),
FullScreenEnterTooltip: cn('fullscreen-enter-tooltip'),
FullScreenExitTooltip: cn('fullscreen-exit-tooltip'),
TimeControls: cn('flex-1 flex items-center gap-3 px-1.5'),
TimeDisplay: cn('tabular-nums text-shadow-2xs shadow-black/50'),
SliderRoot: cn(
@@ -119,7 +145,7 @@ const styles: MediaDefaultSkinStyles = {
'size-2.5 active:size-3 group-active/slider:size-3 hover:cursor-ew-resize'
),
PopoverPopup: cn(
'relative z-30 px-2 py-4 rounded-2xl',
'relative px-2 py-4 rounded-2xl',
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
'ring ring-white/10 ring-inset shadow-sm shadow-black/15',
// Border to enhance contrast on lighter videos
@@ -7,6 +7,10 @@ export interface MediaDefaultSkinStyles {
readonly PlayButton: string;
readonly PlayIcon: string;
readonly PauseIcon: string;
readonly PlayTooltipPopup: string;
readonly PlayTooltip: string;
readonly PauseTooltip: string;
readonly TooltipPopup: string;
readonly VolumeButton: string;
readonly VolumeHighIcon: string;
readonly VolumeLowIcon: string;
@@ -14,12 +18,15 @@ export interface MediaDefaultSkinStyles {
readonly FullScreenButton: string;
readonly FullScreenEnterIcon: string;
readonly FullScreenExitIcon: string;
readonly TimeControls: string;
readonly TimeDisplay: string;
readonly FullScreenTooltipPopup: string;
readonly FullScreenEnterTooltip: string;
readonly FullScreenExitTooltip: string;
readonly SliderRoot: string;
readonly SliderTrack: string;
readonly SliderProgress: string;
readonly SliderPointer: string;
readonly SliderThumb: string;
readonly PopoverPopup: string;
}
readonly TimeControls: string;
readonly TimeDisplay: string;
}
@@ -0,0 +1,60 @@
import * as React from 'react';
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T): (() => void) | void | undefined {
if (typeof ref === 'function') {
return ref(value);
} else if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T>).current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup == 'function') {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup == 'function') {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };