mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat: add React tooltip component (#35)
This commit is contained in:
@@ -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, we’ll 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 };
|
||||
Reference in New Issue
Block a user