mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix: skin syntax usage cleanup (#48)
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vjs-10/react": "workspace:*",
|
||||
"@vjs-10/react-icons": "workspace:*",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"tailwindcss": "^4.1.13"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
|
||||
import { MediaProvider, MediaSkinDefault, MediaSkinToasted, Video } from '@vjs-10/react';
|
||||
|
||||
// NOTE: Commented out imports are for testing locally/externally defined skins.
|
||||
// import { MediaProvider, Video } from '@vjs-10/react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
// import MediaSkinDefault from './skins/frosted/MediaSkinDefault';
|
||||
// import MediaSkinToasted from './skins/toasted/MediaSkinToasted';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const skins = [
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import {
|
||||
CurrentTimeDisplay,
|
||||
DurationDisplay,
|
||||
FullscreenButton,
|
||||
MediaContainer,
|
||||
MuteButton,
|
||||
PlayButton,
|
||||
Popover,
|
||||
TimeSlider,
|
||||
Tooltip,
|
||||
VolumeSlider,
|
||||
} from '@vjs-10/react';
|
||||
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@vjs-10/react-icons';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
type SkinProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export default function MediaSkinDefault({ children, className = '' }: SkinProps): JSX.Element {
|
||||
return (
|
||||
<MediaContainer 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">
|
||||
<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>
|
||||
<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
|
||||
// Use showRemaining to show count down/remaining time
|
||||
// showRemaining
|
||||
className={styles.TimeDisplay}
|
||||
/>
|
||||
|
||||
<TimeSlider.Root className={styles.SliderRoot}>
|
||||
<TimeSlider.Track className={styles.SliderTrack}>
|
||||
<TimeSlider.Progress className={styles.SliderProgress} />
|
||||
<TimeSlider.Pointer className={styles.SliderPointer} />
|
||||
</TimeSlider.Track>
|
||||
<TimeSlider.Thumb className={styles.SliderThumb} />
|
||||
</TimeSlider.Root>
|
||||
|
||||
<DurationDisplay className={styles.TimeDisplay} />
|
||||
</div>
|
||||
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100}>
|
||||
<Popover.Trigger>
|
||||
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
|
||||
<VolumeHighIcon className={styles.VolumeHighIcon} />
|
||||
<VolumeLowIcon className={styles.VolumeLowIcon} />
|
||||
<VolumeOffIcon className={styles.VolumeOffIcon} />
|
||||
</MuteButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner side="top" sideOffset={12}>
|
||||
<Popover.Popup className={styles.PopoverPopup}>
|
||||
<VolumeSlider.Root className={styles.SliderRoot} orientation="vertical">
|
||||
<VolumeSlider.Track className={styles.SliderTrack}>
|
||||
<VolumeSlider.Progress className={styles.SliderProgress} />
|
||||
</VolumeSlider.Track>
|
||||
<VolumeSlider.Thumb className={styles.SliderThumb} />
|
||||
</VolumeSlider.Root>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { MediaDefaultSkinStyles } from './types';
|
||||
|
||||
// NOTE: Removing import to sidestep for compiler complexity (CJP)
|
||||
// import { cn } from '../../utils/cn';
|
||||
// A (very crude) utility to merge class names
|
||||
// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case.
|
||||
// It just makes the billions of Tailwind classes a little easier to read.
|
||||
function cn(...classes: (string | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const styles: MediaDefaultSkinStyles = {
|
||||
MediaContainer: cn(
|
||||
'relative @container/root group/root overflow-clip',
|
||||
// Base typography
|
||||
'text-sm',
|
||||
// Prevent rounded corners in fullscreen.
|
||||
'[&:fullscreen]:rounded-none [&:fullscreen]:[&_video]:h-full [&:fullscreen]:[&_video]:w-full',
|
||||
// Fancy borders.
|
||||
'after:absolute after:inset-0 after:ring-black/10 after:ring-1 dark:after:ring-black/40 after:ring-inset after:z-10 after:pointer-events-none after:rounded-[inherit]',
|
||||
'before:absolute before:inset-px before:rounded-[inherit] before:ring-white/15 before:ring-1 before:ring-inset before:z-10 before:pointer-events-none',
|
||||
// Ensure the nested video inherits the radius.
|
||||
'[&_video]:rounded-[inherit] [&_video]:w-full [&_video]:h-auto',
|
||||
),
|
||||
Overlay: cn(
|
||||
'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',
|
||||
'group-hover/root:opacity-100 group-hover/root:delay-0',
|
||||
),
|
||||
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 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',
|
||||
// Animation
|
||||
'transition will-change-transform origin-bottom ease-out',
|
||||
// FIXME: Temporary hide/show logic
|
||||
'scale-90 opacity-0 delay-500',
|
||||
'has-[[data-paused]]:scale-100 has-[[data-paused]]:opacity-100 has-[[data-paused]]:delay-0',
|
||||
'group-hover/root:scale-100 group-hover/root:opacity-100 group-hover/root:delay-0',
|
||||
// Border to enhance contrast on lighter videos
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-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',
|
||||
),
|
||||
Button: cn(
|
||||
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-full',
|
||||
// Background/foreground
|
||||
'bg-transparent text-white/90',
|
||||
// Hover and focus states
|
||||
'hover:no-underline hover:bg-white/10 hover:text-white focus-visible:no-underline focus-visible:bg-white/10 focus-visible:text-white',
|
||||
// Focus state
|
||||
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500',
|
||||
// Disabled state
|
||||
'aria-disabled:grayscale aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
|
||||
// Loading state
|
||||
'aria-busy:pointer-events-none aria-busy:cursor-not-allowed',
|
||||
// Expanded state
|
||||
'aria-expanded:bg-white/10 aria-expanded:text-white',
|
||||
// Pressed state
|
||||
'active:scale-95',
|
||||
),
|
||||
IconButton: cn(
|
||||
'grid [&_svg]:[grid-area:1/1]',
|
||||
'[&_svg]:shrink-0 [&_svg]:transition-opacity [&_svg]:duration-300 [&_svg]:ease-out [&_svg]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_svg]:shadow-black/20',
|
||||
),
|
||||
PlayButton: cn(
|
||||
'[&_.pause-icon]:opacity-100 [&[data-paused]_.pause-icon]:opacity-0',
|
||||
'[&_.play-icon]:opacity-0 [&[data-paused]_.play-icon]:opacity-100',
|
||||
),
|
||||
PlayIcon: cn('play-icon'),
|
||||
PauseIcon: cn('pause-icon'),
|
||||
TooltipPopup: cn(
|
||||
'whitespace-nowrap flex origin-[var(--transform-origin)] flex-col rounded-md text-white text-xs @7xl/root:text-sm px-2 py-1',
|
||||
// Background
|
||||
'bg-white/10 backdrop-blur-3xl backdrop-saturate-150 backdrop-brightness-90',
|
||||
// Animation
|
||||
'transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[instant]:duration-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0',
|
||||
// Ring
|
||||
'ring-1 ring-white/10 ring-inset',
|
||||
// Text shadow
|
||||
'text-shadow shadow-black/10',
|
||||
// Border to enhance contrast on lighter videos
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none',
|
||||
),
|
||||
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'),
|
||||
MuteButton: cn(
|
||||
'[&_svg]:opacity-0',
|
||||
'[&[data-volume-level="high"]_.volume-high-icon]:opacity-100',
|
||||
'[&[data-volume-level="medium"]_.volume-low-icon]:opacity-100',
|
||||
'[&[data-volume-level="low"]_.volume-low-icon]:opacity-100',
|
||||
'[&[data-volume-level="off"]_.volume-off-icon]:opacity-100',
|
||||
),
|
||||
VolumeHighIcon: cn('volume-high-icon'),
|
||||
VolumeLowIcon: cn('volume-low-icon'),
|
||||
VolumeOffIcon: cn('volume-off-icon'),
|
||||
FullScreenButton: cn(
|
||||
'[&_.fullscreen-enter-icon]:opacity-100 [&[data-fullscreen]_.fullscreen-enter-icon]:opacity-0',
|
||||
'[&_.fullscreen-exit-icon]:opacity-0 [&[data-fullscreen]_.fullscreen-exit-icon]:opacity-100',
|
||||
'[&_path]:transition-transform ease-out',
|
||||
),
|
||||
FullScreenEnterIcon: cn(
|
||||
'fullscreen-enter-icon',
|
||||
'group-hover/button:[&_.arrow-1]:-translate-x-px group-hover/button:[&_.arrow-1]:-translate-y-px',
|
||||
'group-hover/button:[&_.arrow-2]:translate-x-px group-hover/button:[&_.arrow-2]:translate-y-px',
|
||||
),
|
||||
FullScreenExitIcon: cn(
|
||||
'fullscreen-exit-icon',
|
||||
'[&_.arrow-1]:-translate-x-px [&_.arrow-1]:-translate-y-px',
|
||||
'[&_.arrow-2]:translate-x-px [&_.arrow-2]:translate-y-px',
|
||||
'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(
|
||||
'flex items-center justify-center flex-1 group/slider relative',
|
||||
'[&[data-orientation="horizontal"]]:h-5 [&[data-orientation="horizontal"]]:min-w-20',
|
||||
'[&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20',
|
||||
),
|
||||
SliderTrack: cn(
|
||||
'w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5',
|
||||
'[&[data-orientation="horizontal"]]:h-1',
|
||||
'[&[data-orientation="vertical"]]:w-1',
|
||||
),
|
||||
SliderProgress: cn('bg-white rounded-[inherit]'),
|
||||
// TODO: Work out what we want to do here.
|
||||
SliderPointer: cn('rounded-[inherit]'),
|
||||
SliderThumb: cn(
|
||||
'bg-white z-10 select-none ring ring-black/10 rounded-full shadow-sm shadow-black/15',
|
||||
'opacity-0 transition-[opacity,height,width] ease-in-out',
|
||||
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500',
|
||||
'group-hover/slider:opacity-100 group-focus-within/slider:opacity-100',
|
||||
'size-2.5 active:size-3 group-active/slider:size-3 hover:cursor-ew-resize',
|
||||
),
|
||||
PopoverPopup: cn(
|
||||
'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
|
||||
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-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',
|
||||
),
|
||||
};
|
||||
|
||||
/*
|
||||
[1] @custom-variant reduced-transparency @media (prefers-reduced-transparency: reduce);
|
||||
*/
|
||||
|
||||
export default styles;
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface MediaDefaultSkinStyles {
|
||||
readonly MediaContainer: string;
|
||||
readonly Overlay: string;
|
||||
readonly Controls: string;
|
||||
readonly Button: string;
|
||||
readonly IconButton: string;
|
||||
readonly PlayButton: string;
|
||||
readonly PlayIcon: string;
|
||||
readonly PauseIcon: string;
|
||||
readonly PlayTooltipPopup: string;
|
||||
readonly PlayTooltip: string;
|
||||
readonly PauseTooltip: string;
|
||||
readonly TooltipPopup: string;
|
||||
readonly MuteButton: string;
|
||||
readonly VolumeHighIcon: string;
|
||||
readonly VolumeLowIcon: string;
|
||||
readonly VolumeOffIcon: string;
|
||||
readonly FullScreenButton: string;
|
||||
readonly FullScreenEnterIcon: string;
|
||||
readonly FullScreenExitIcon: 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,75 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import {
|
||||
CurrentTimeDisplay,
|
||||
DurationDisplay,
|
||||
FullscreenButton,
|
||||
MediaContainer,
|
||||
MuteButton,
|
||||
PlayButton,
|
||||
TimeSlider,
|
||||
} from '@vjs-10/react';
|
||||
|
||||
import {
|
||||
FullscreenEnterIcon,
|
||||
FullscreenExitIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeOffIcon,
|
||||
} from '@vjs-10/react-icons';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
type SkinProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export default function MediaSkinToasted({ children, className = '' }: SkinProps): JSX.Element {
|
||||
return (
|
||||
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
|
||||
{children}
|
||||
|
||||
<div className={styles.Overlay} aria-hidden="true" />
|
||||
|
||||
<div className={styles.Controls}>
|
||||
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
|
||||
<PlayIcon className={`${styles.PlayIcon} ${styles.Icon}`} />
|
||||
<PauseIcon className={`${styles.PauseIcon} ${styles.Icon}`} />
|
||||
</PlayButton>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<CurrentTimeDisplay
|
||||
// Use showRemaining to show count down/remaining time
|
||||
// showRemaining
|
||||
className={styles.TimeDisplay}
|
||||
/>
|
||||
<span className="opacity-50">/</span>
|
||||
<DurationDisplay className={`${styles.TimeDisplay} opacity-50`} />
|
||||
</div>
|
||||
|
||||
<TimeSlider.Root className={`${styles.SliderRoot} ${styles.TimeSliderRoot}`}>
|
||||
<TimeSlider.Track className={styles.SliderTrack}>
|
||||
<TimeSlider.Progress className={styles.SliderProgress} />
|
||||
<TimeSlider.Pointer className={styles.SliderPointer} />
|
||||
</TimeSlider.Track>
|
||||
<TimeSlider.Thumb className={`${styles.SliderThumb} ${styles.TimeSliderThumb}`} />
|
||||
</TimeSlider.Root>
|
||||
|
||||
<div className={styles.ButtonGroup}>
|
||||
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
|
||||
<VolumeHighIcon className={`${styles.VolumeHighIcon} ${styles.Icon}`} />
|
||||
<VolumeLowIcon className={`${styles.VolumeLowIcon} ${styles.Icon}`} />
|
||||
<VolumeOffIcon className={`${styles.VolumeOffIcon} ${styles.Icon}`} />
|
||||
</MuteButton>
|
||||
|
||||
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullscreenButton}`}>
|
||||
<FullscreenEnterIcon className={`${styles.FullscreenEnterIcon} ${styles.Icon}`} />
|
||||
<FullscreenExitIcon className={`${styles.FullscreenExitIcon} ${styles.Icon}`} />
|
||||
</FullscreenButton>
|
||||
</div>
|
||||
</div>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MediaSkinToasted } from './MediaSkinToasted';
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { MediaToastedSkinStyles } from './types';
|
||||
|
||||
// NOTE: Removing import to sidestep for compiler complexity (CJP)
|
||||
// import { cn } from '../../utils/cn';
|
||||
// A (very crude) utility to merge class names
|
||||
// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case.
|
||||
// It just makes the billions of Tailwind classes a little easier to read.
|
||||
function cn(...classes: (string | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const styles: MediaToastedSkinStyles = {
|
||||
MediaContainer: cn(
|
||||
'relative @container/root group/root overflow-clip bg-black',
|
||||
// Base typography
|
||||
'text-[0.8125rem]', // 13px
|
||||
// 'ring-1 ring-inset ring-black/10 dark:ring-white/10',
|
||||
'after:absolute after:inset-0 after:ring-black/10 after:ring-1 dark:after:ring-white/10 after:ring-inset after:z-10 after:pointer-events-none after:rounded-[inherit]',
|
||||
// Prevent rounded corners in fullscreen.
|
||||
'[&:fullscreen]:rounded-none [&:fullscreen]:[&_video]:h-full [&:fullscreen]:[&_video]:w-full',
|
||||
// Ensure the nested video inherits the radius.
|
||||
'[&_video]:rounded-[inherit] [&_video]:w-full [&_video]:h-auto',
|
||||
),
|
||||
Overlay: cn(
|
||||
'absolute inset-0 rounded-[inherit] bg-black/30',
|
||||
'bg-gradient-to-t from-black/30 to-transparent to-[120px]',
|
||||
'opacity-0 delay-500 duration-300',
|
||||
// FIXME: Temporary hide/show logic
|
||||
'has-[+.controls_[data-paused]]:opacity-100 has-[+.controls_[data-paused]]:delay-0 has-[+.controls_[data-paused]]:duration-75',
|
||||
'group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:duration-75',
|
||||
),
|
||||
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-0 bottom-0 flex items-center gap-3.5 z-20 px-6 pb-6 pt-10 text-white text-shadow',
|
||||
'shadow-sm shadow-black/15',
|
||||
// Animation
|
||||
'transition ease-in-out',
|
||||
// FIXME: Temporary hide/show logic
|
||||
'translate-y-full opacity-0 delay-500 duration-300',
|
||||
'has-[[data-paused]]:translate-y-0 has-[[data-paused]]:opacity-100 has-[[data-paused]]:delay-0 has-[[data-paused]]:duration-75',
|
||||
'group-hover/root:translate-y-0 group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:duration-75',
|
||||
),
|
||||
Icon: cn('icon'),
|
||||
Button: cn(
|
||||
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-md',
|
||||
// Background/foreground
|
||||
'bg-transparent text-white',
|
||||
// Hover and focus states
|
||||
'hover:text-white/70 focus-visible:text-white/70',
|
||||
// Focus state
|
||||
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white',
|
||||
// Disabled state
|
||||
'aria-disabled:grayscale aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
|
||||
// Loading state
|
||||
'aria-busy:pointer-events-none aria-busy:cursor-not-allowed',
|
||||
// Expanded state
|
||||
'aria-expanded:text-white/70',
|
||||
// Pressed state
|
||||
'active:scale-95',
|
||||
),
|
||||
ButtonGroup: cn('flex items-center gap-1.5'),
|
||||
IconButton: cn(
|
||||
'grid [&_.icon]:[grid-area:1/1]',
|
||||
'[&_.icon]:shrink-0 [&_.icon]:transition [&_.icon]:duration-300 [&_.icon]:ease-out [&_.icon]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_.icon]:shadow-black/20',
|
||||
),
|
||||
PlayButton: cn(
|
||||
'[&_.pause-icon]:opacity-100 [&[data-paused]_.pause-icon]:opacity-0',
|
||||
'[&_.play-icon]:opacity-0 [&[data-paused]_.play-icon]:opacity-100',
|
||||
),
|
||||
PlayIcon: cn('play-icon'),
|
||||
PauseIcon: cn('pause-icon'),
|
||||
MuteButton: cn(
|
||||
'[&_.icon]:hidden',
|
||||
'[&[data-volume-level="high"]_.volume-high-icon]:inline',
|
||||
'[&[data-volume-level="medium"]_.volume-low-icon]:inline',
|
||||
'[&[data-volume-level="low"]_.volume-low-icon]:inline',
|
||||
'[&[data-volume-level="off"]_.volume-off-icon]:inline',
|
||||
),
|
||||
VolumeHighIcon: cn('volume-high-icon'),
|
||||
VolumeLowIcon: cn('volume-low-icon'),
|
||||
VolumeOffIcon: cn('volume-off-icon'),
|
||||
FullscreenButton: cn(
|
||||
'[&_.fullscreen-enter-icon]:opacity-100 [&[data-fullscreen]_.fullscreen-enter-icon]:opacity-0',
|
||||
'[&_.fullscreen-exit-icon]:opacity-0 [&[data-fullscreen]_.fullscreen-exit-icon]:opacity-100',
|
||||
'[&_path]:transition-transform ease-out',
|
||||
),
|
||||
FullscreenEnterIcon: cn(
|
||||
'fullscreen-enter-icon',
|
||||
'group-hover/button:[&_.arrow-1]:-translate-x-px group-hover/button:[&_.arrow-1]:-translate-y-px',
|
||||
'group-hover/button:[&_.arrow-2]:translate-x-px group-hover/button:[&_.arrow-2]:translate-y-px',
|
||||
),
|
||||
FullscreenExitIcon: cn(
|
||||
'fullscreen-exit-icon',
|
||||
'[&_.arrow-1]:-translate-x-px [&_.arrow-1]:-translate-y-px',
|
||||
'[&_.arrow-2]:translate-x-px [&_.arrow-2]:translate-y-px',
|
||||
'group-hover/button:[&_.arrow-1]:translate-0',
|
||||
'group-hover/button:[&_.arrow-2]:translate-0',
|
||||
),
|
||||
TimeSliderRoot: cn('mx-2'),
|
||||
TimeSliderThumb: cn('opacity-0'),
|
||||
TimeDisplay: cn('tabular-nums text-shadow-2xs shadow-black/50'),
|
||||
SliderRoot: cn(
|
||||
'flex items-center justify-center flex-1 group/slider relative',
|
||||
'[&[data-orientation="horizontal"]]:h-5 [&[data-orientation="horizontal"]]:min-w-20',
|
||||
'[&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20',
|
||||
),
|
||||
SliderTrack: cn(
|
||||
'relative select-none rounded-full bg-white/10',
|
||||
'[&[data-orientation="horizontal"]]:w-full [&[data-orientation="horizontal"]]:h-1',
|
||||
'[&[data-orientation="vertical"]]:w-1',
|
||||
),
|
||||
SliderProgress: cn('bg-white rounded-[inherit]'),
|
||||
SliderPointer: cn('hidden'),
|
||||
SliderThumb: cn(
|
||||
'bg-white z-10 select-none ring ring-black/10 rounded-full shadow-sm shadow-black/15 transition-[opacity,height,width] ease-in-out',
|
||||
'-outline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500',
|
||||
'size-3 active:size-3.5 group-active/slider:size-3.5 hover:cursor-ew-resize',
|
||||
),
|
||||
};
|
||||
|
||||
export default styles;
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface MediaToastedSkinStyles {
|
||||
readonly MediaContainer: string;
|
||||
readonly Overlay: string;
|
||||
readonly Controls: string;
|
||||
readonly Icon: string;
|
||||
readonly Button: string;
|
||||
readonly ButtonGroup: string;
|
||||
readonly IconButton: string;
|
||||
readonly PlayButton: string;
|
||||
readonly PlayIcon: string;
|
||||
readonly PauseIcon: string;
|
||||
readonly MuteButton: string;
|
||||
readonly VolumeHighIcon: string;
|
||||
readonly VolumeLowIcon: string;
|
||||
readonly VolumeOffIcon: string;
|
||||
readonly FullscreenButton: string;
|
||||
readonly FullscreenEnterIcon: string;
|
||||
readonly FullscreenExitIcon: string;
|
||||
readonly TimeSliderRoot: string;
|
||||
readonly TimeSliderThumb: string;
|
||||
readonly TimeDisplay: string;
|
||||
readonly SliderRoot: string;
|
||||
readonly SliderTrack: string;
|
||||
readonly SliderProgress: string;
|
||||
readonly SliderPointer: string;
|
||||
readonly SliderThumb: string;
|
||||
}
|
||||
@@ -89,7 +89,6 @@ export const CurrentTimeDisplay: ConnectedComponentConstructor<CurrentTimeDispla
|
||||
|
||||
// Register the custom element
|
||||
if (!globalThis.customElements.get('media-current-time-display')) {
|
||||
// @ts-ignore - Custom element constructor compatibility
|
||||
globalThis.customElements.define('media-current-time-display', CurrentTimeDisplay);
|
||||
}
|
||||
|
||||
|
||||
@@ -233,13 +233,23 @@ export class MediaTooltipPortal extends HTMLElement {
|
||||
}
|
||||
|
||||
#setupPortal(): void {
|
||||
const portalId = this.getAttribute('root-id');
|
||||
const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
|
||||
if (!portalId) return;
|
||||
|
||||
const portalContainer = (this.getRootNode() as ShadowRoot | Document).getElementById(portalId);
|
||||
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
|
||||
// NOTE: Hacky solution in part to ensure styling propogates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
|
||||
const portalContainer
|
||||
= ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
|
||||
?? (this.getRootNode() as ShadowRoot | Document)
|
||||
.querySelector('media-container')
|
||||
?.shadowRoot
|
||||
?.getElementById(portalId))
|
||||
? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
|
||||
: undefined;
|
||||
if (!portalContainer) return;
|
||||
|
||||
this.#portal = document.createElement('div');
|
||||
this.#portal.slot = 'portal';
|
||||
this.#portal.id = uniqueId();
|
||||
portalContainer.append(this.#portal);
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export { CurrentTimeDisplay } from './components/media-current-time-display.js';
|
||||
export { DurationDisplay } from './components/media-duration-display.js';
|
||||
|
||||
export { FullscreenButton } from './components/media-fullscreen-button.js';
|
||||
export { MuteButton } from './components/media-mute-button.js';
|
||||
// New hook-style components
|
||||
export { PlayButton } from './components/media-play-button.js';
|
||||
export { Popover } from './components/media-popover.js';
|
||||
export { TimeSlider } from './components/media-time-slider.js';
|
||||
export { Tooltip } from './components/media-tooltip.js';
|
||||
export { VolumeSlider } from './components/media-volume-slider.js';
|
||||
export * as MediaProvider from './media-provider.js';
|
||||
export { MediaSkin } from './media-skin.js';
|
||||
export * as MediaThemeDefault from './skins/media-skin-default.js';
|
||||
|
||||
export function defineVjsPlayer(): void {
|
||||
|
||||
@@ -2,10 +2,14 @@ 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 */ `
|
||||
<slot name="media"></slot>
|
||||
<slot></slot>
|
||||
<div id="@default_portal_id" style={ position: absolute; zIndex: 10; }>
|
||||
<slot name="portal"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -28,15 +32,11 @@ export class MediaContainer extends CustomElementConsumer {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// @ts-ignore - Shadow DOM property access
|
||||
if (!this.shadowRoot) {
|
||||
// @ts-ignore - Shadow DOM property access
|
||||
this.attachShadow((this.constructor as typeof MediaContainer).shadowRootOptions);
|
||||
// @ts-ignore - Shadow DOM property access
|
||||
this.shadowRoot!.innerHTML = (this.constructor as typeof MediaContainer).getTemplateHTML();
|
||||
}
|
||||
|
||||
// @ts-ignore - Shadow DOM property access
|
||||
this._mediaSlot = this.shadowRoot!.querySelector('slot[name=media]') as HTMLSlotElement;
|
||||
this._mediaSlot.addEventListener('slotchange', this._handleMediaSlotChange);
|
||||
}
|
||||
@@ -67,5 +67,8 @@ export class MediaContainer extends CustomElementConsumer {
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore - Custom elements type compatibility
|
||||
customElements.define('media-container', MediaContainer);
|
||||
// Register the custom element
|
||||
if (!globalThis.customElements.get('media-container')) {
|
||||
// @ts-expect-error ts(2345)
|
||||
globalThis.customElements.define('media-container', MediaContainer);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MediaSkin } from '../media-skin';
|
||||
import { uniqueId } from '../utils/element-utils';
|
||||
|
||||
import '../media-container';
|
||||
import '../components/media-play-button';
|
||||
@@ -14,9 +13,7 @@ import '../components/media-tooltip';
|
||||
import '@vjs-10/html-icons';
|
||||
|
||||
export function getTemplateHTML() {
|
||||
const portalId = uniqueId();
|
||||
|
||||
return /* html */ `
|
||||
return /* html */`
|
||||
${MediaSkin.getTemplateHTML()}
|
||||
<style>
|
||||
/** @TODO: Improve/Polish CSS Here */
|
||||
@@ -246,11 +243,6 @@ export function getTemplateHTML() {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.portal {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Tooltip Component Styles */
|
||||
media-tooltip-popup {
|
||||
background: rgb(20 20 30 / .9);
|
||||
@@ -279,7 +271,6 @@ export function getTemplateHTML() {
|
||||
</style>
|
||||
<media-container>
|
||||
<slot name="media" slot="media"></slot>
|
||||
<div id="${portalId}" class="portal"></div>
|
||||
<div class="overlay">
|
||||
<div class="spacer"></div>
|
||||
<div class="control-bar">
|
||||
@@ -291,7 +282,7 @@ export function getTemplateHTML() {
|
||||
<media-pause-icon class="icon pause-icon"></media-pause-icon>
|
||||
</media-play-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-portal root-id="${portalId}">
|
||||
<media-tooltip-portal>
|
||||
<media-tooltip-positioner side="top" side-offset="8" collision-padding="8">
|
||||
<media-tooltip-popup>
|
||||
<span class="tooltip play-tooltip">Play</span>
|
||||
@@ -318,7 +309,7 @@ export function getTemplateHTML() {
|
||||
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
|
||||
</media-mute-button>
|
||||
</media-popover-trigger>
|
||||
<media-popover-portal root-id="${portalId}">
|
||||
<media-popover-portal>
|
||||
<media-popover-positioner side="top" side-offset="0">
|
||||
<media-popover-popup>
|
||||
<media-volume-slider-root orientation="vertical">
|
||||
@@ -338,7 +329,7 @@ export function getTemplateHTML() {
|
||||
<media-fullscreen-exit-icon class="icon fullscreen-exit-icon"></media-fullscreen-exit-icon>
|
||||
</media-fullscreen-button>
|
||||
</media-tooltip-trigger>
|
||||
<media-tooltip-portal root-id="${portalId}">
|
||||
<media-tooltip-portal>
|
||||
<media-tooltip-positioner side="top" side-offset="8" collision-padding="8">
|
||||
<media-tooltip-popup>
|
||||
<span class="tooltip fullscreen-enter-tooltip">Enter Fullscreen</span>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function toConnectedHTMLComponent<State = any>(
|
||||
const ConnectedComponent = class extends ConsumerMixin(BaseClass) {
|
||||
static get observedAttributes(): string[] {
|
||||
return [
|
||||
// @ts-ignore
|
||||
// @ts-expect-error ts(2339)
|
||||
...(super.observedAttributes ?? []),
|
||||
];
|
||||
}
|
||||
@@ -53,9 +53,8 @@ export function toConnectedHTMLComponent<State = any>(
|
||||
const state = stateHook.transform(rawState, mediaStore);
|
||||
|
||||
// Phase 2: Update element attributes/properties (props concern)
|
||||
// @ts-ignore - Element property access
|
||||
const props = propsHook(state ?? {}, this);
|
||||
// @ts-ignore
|
||||
const props = propsHook(state ?? {} as State, this);
|
||||
// @ts-expect-error any
|
||||
this._update(props, state, mediaStore);
|
||||
});
|
||||
},
|
||||
@@ -70,7 +69,7 @@ export function toConnectedHTMLComponent<State = any>(
|
||||
}
|
||||
|
||||
handleEvent(event: CustomEvent): void {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error any
|
||||
super.handleEvent?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,13 +52,15 @@ export function useMediaContainerRef(): RefCallback<HTMLElement | null> {
|
||||
* </MediaContainer>
|
||||
* );
|
||||
*/
|
||||
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement>>> = forwardRef(
|
||||
({ children, ...props }, ref) => {
|
||||
export const MediaContainer: FC<PropsWithChildren<HTMLProps<HTMLDivElement> & { portalId?: string }>> = forwardRef(
|
||||
({ children, portalId = '@default_portal_id', ...props }, ref) => {
|
||||
const containerRef = useMediaContainerRef();
|
||||
const composedRef = useComposedRefs(ref, containerRef);
|
||||
return (
|
||||
<div ref={composedRef} {...props}>
|
||||
{children}
|
||||
{/* @TODO We need to make sure this is non-brittle longer term (CJP) */}
|
||||
<div id={portalId} style={{ position: 'absolute', zIndex: 10 }} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -236,7 +236,7 @@ function TooltipArrow({ className = '', children }: TooltipArrowProps): JSX.Elem
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipPortal({ children, root, rootId }: TooltipPortalProps): JSX.Element {
|
||||
function TooltipPortal({ children, root, rootId = '@default_portal_id' }: TooltipPortalProps): JSX.Element {
|
||||
return (
|
||||
<FloatingPortal root={root as HTMLElement} id={rootId as string}>
|
||||
{children}
|
||||
|
||||
@@ -2,10 +2,11 @@ export { CurrentTimeDisplay } from './components/CurrentTimeDisplay';
|
||||
export { DurationDisplay } from './components/DurationDisplay';
|
||||
export { FullscreenButton } from './components/FullscreenButton';
|
||||
export { MediaContainer, useMediaContainerRef } from './components/MediaContainer';
|
||||
|
||||
export { MuteButton } from './components/MuteButton';
|
||||
export { PlayButton } from './components/PlayButton';
|
||||
export { Popover } from './components/Popover';
|
||||
export { TimeSlider } from './components/TimeSlider';
|
||||
export { Tooltip } from './components/Tooltip';
|
||||
export { MediaElementVideo, Video } from './components/Video';
|
||||
export { VolumeSlider } from './components/VolumeSlider';
|
||||
export * from './skins';
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
VolumeOffIcon,
|
||||
} from '@vjs-10/react-icons';
|
||||
|
||||
import { useId } from 'react';
|
||||
|
||||
import { CurrentTimeDisplay } from '../../components/CurrentTimeDisplay';
|
||||
|
||||
import { DurationDisplay } from '../../components/DurationDisplay';
|
||||
@@ -30,7 +28,6 @@ type SkinProps = PropsWithChildren<{
|
||||
}>;
|
||||
|
||||
export default function MediaSkinDefault({ children, className = '' }: SkinProps): JSX.Element {
|
||||
const portalId = useId();
|
||||
return (
|
||||
<MediaContainer className={`${styles.MediaContainer} ${className}`}>
|
||||
{children}
|
||||
@@ -42,11 +39,11 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
|
||||
<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>
|
||||
<PlayIcon className={`${styles.PlayIcon} ${styles.Icon}`} />
|
||||
<PauseIcon className={`${styles.PauseIcon} ${styles.Icon}`} />
|
||||
</PlayButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal rootId={portalId}>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Positioner side="top" sideOffset={12} collisionPadding={12}>
|
||||
<Tooltip.Popup className={`${styles.TooltipPopup} ${styles.PlayTooltipPopup}`}>
|
||||
<span className={styles.PlayTooltip}>Play</span>
|
||||
@@ -77,12 +74,12 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100}>
|
||||
<Popover.Trigger>
|
||||
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
|
||||
<VolumeHighIcon className={styles.VolumeHighIcon} />
|
||||
<VolumeLowIcon className={styles.VolumeLowIcon} />
|
||||
<VolumeOffIcon className={styles.VolumeOffIcon} />
|
||||
<VolumeHighIcon className={`${styles.VolumeHighIcon} ${styles.Icon}`} />
|
||||
<VolumeLowIcon className={`${styles.VolumeLowIcon} ${styles.Icon}`} />
|
||||
<VolumeOffIcon className={`${styles.VolumeOffIcon} ${styles.Icon}`} />
|
||||
</MuteButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal rootId={portalId}>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner side="top" sideOffset={12}>
|
||||
<Popover.Popup className={styles.PopoverPopup}>
|
||||
<VolumeSlider.Root className={styles.SliderRoot} orientation="vertical">
|
||||
@@ -98,23 +95,21 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
|
||||
|
||||
<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 className={`${styles.Button} ${styles.IconButton} ${styles.FullscreenButton}`}>
|
||||
<FullscreenEnterIcon className={`${styles.FullscreenEnterIcon} ${styles.Icon}`} />
|
||||
<FullscreenExitIcon className={`${styles.FullscreenExitIcon} ${styles.Icon}`} />
|
||||
</FullscreenButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal rootId={portalId}>
|
||||
<Tooltip.Portal>
|
||||
<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 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 id={portalId} className="absolute z-10" />
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/** @TODO: Improve/Polish CSS Here */
|
||||
/* Media Container UI/Styles */
|
||||
.Container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
/* NOTE: Setting color here for generic inheritance, including SVG fill: currentColor defaults (CJP) */
|
||||
color: rgb(238 238 238);
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: start;
|
||||
/* pointer-events: none; */
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generic Media Button Styling */
|
||||
.Button {
|
||||
border: none;
|
||||
background: rgb(20 20 30 / 0.7);
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: rgb(238 238 238);
|
||||
|
||||
/* @TODO Currently, the web component version of icons "bakes in" these styles. Determine which approach we prefer (CJP) */
|
||||
.Icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/*
|
||||
.MediaPlayButton:not([data-paused]) .PauseIcon,
|
||||
.MediaPlayButton[data-paused] .PlayIcon {
|
||||
display: inline-block;
|
||||
} */
|
||||
|
||||
/* One way to define the "default visible" icon (CJP) */
|
||||
.MediaMuteButton:not([data-volume-level]) .VolumeLowIcon,
|
||||
.MediaMuteButton[data-volume-level='high'] .VolumeHighIcon,
|
||||
.MediaMuteButton[data-volume-level='low'] .VolumeLowIcon,
|
||||
.MediaMuteButton[data-volume-level='medium'] .VolumeLowIcon,
|
||||
.MediaMuteButton[data-volume-level='off'] .VolumeOffIcon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Fullscreen button icon states */
|
||||
.MediaFullscreenButton:not([data-fullscreen]) .FullscreenEnterIcon,
|
||||
.MediaFullscreenButton[data-fullscreen] .FullscreenExitIcon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Media Control Bar UI/Styles */
|
||||
.ControlBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Time Display Styling */
|
||||
.TimeDisplay {
|
||||
background: rgb(20 20 30 / 0.7);
|
||||
padding: 4px 8px;
|
||||
color: rgb(238 238 238);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
border-radius: 2px;
|
||||
min-width: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Base Button Styling */
|
||||
.MediaPlayButton {
|
||||
border: none;
|
||||
background: rgb(20 20 30 / 0.7);
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: rgb(238 238 238);
|
||||
|
||||
/* Basic Icon Styling for Buttons */
|
||||
.PlayIcon,
|
||||
.PauseIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: none; /* Hide icons by default */
|
||||
}
|
||||
|
||||
/* Show PlayIcon if paused and PauseIcon if unpaused */
|
||||
&:not([data-paused]) .PauseIcon,
|
||||
&[data-paused] .PlayIcon {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/* TimeSlider Component Styles */
|
||||
.TimeSliderRoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
padding-block: 0.75rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.TimeSliderTrack {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.TimeSliderThumb {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.TimeSliderPointer {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.TimeSliderProgress {
|
||||
background-color: #007bff;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* VolumeSlider Component Styles */
|
||||
.VolumeSliderRoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 80px;
|
||||
width: 80px;
|
||||
padding-block: 0.75rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.VolumeSliderTrack {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.VolumeSliderThumb {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.VolumeSliderProgress {
|
||||
background-color: #007bff;
|
||||
border-radius: inherit;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { MediaDefaultSkinStyles } from './types';
|
||||
|
||||
import { cn } from '../../utils/cn';
|
||||
// NOTE: Removing import to sidestep for compiler complexity (CJP)
|
||||
// import { cn } from '../../utils/cn';
|
||||
// A (very crude) utility to merge class names
|
||||
// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case.
|
||||
// It just makes the billions of Tailwind classes a little easier to read.
|
||||
function cn(...classes: (string | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const styles: MediaDefaultSkinStyles = {
|
||||
MediaContainer: cn(
|
||||
@@ -42,6 +49,7 @@ const styles: MediaDefaultSkinStyles = {
|
||||
// High contrast mode
|
||||
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20',
|
||||
),
|
||||
Icon: cn('icon'),
|
||||
Button: cn(
|
||||
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-full',
|
||||
// Background/foreground
|
||||
@@ -60,8 +68,8 @@ const styles: MediaDefaultSkinStyles = {
|
||||
'active:scale-95',
|
||||
),
|
||||
IconButton: cn(
|
||||
'grid [&_svg]:[grid-area:1/1]',
|
||||
'[&_svg]:shrink-0 [&_svg]:transition-opacity [&_svg]:duration-300 [&_svg]:ease-out [&_svg]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_svg]:shadow-black/20',
|
||||
'grid [&_.icon]:[grid-area:1/1]',
|
||||
'[&_.icon]:shrink-0 [&_.icon]:transition-opacity [&_.icon]:duration-300 [&_.icon]:ease-out [&_.icon]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_.icon]:shadow-black/20',
|
||||
),
|
||||
PlayButton: cn(
|
||||
'[&_.pause-icon]:opacity-100 [&[data-paused]_.pause-icon]:opacity-0',
|
||||
@@ -89,7 +97,7 @@ const styles: MediaDefaultSkinStyles = {
|
||||
PlayTooltip: cn('play-tooltip'),
|
||||
PauseTooltip: cn('pause-tooltip'),
|
||||
MuteButton: cn(
|
||||
'[&_svg]:opacity-0',
|
||||
'[&_.icon]:opacity-0',
|
||||
'[&[data-volume-level="high"]_.volume-high-icon]:opacity-100',
|
||||
'[&[data-volume-level="medium"]_.volume-low-icon]:opacity-100',
|
||||
'[&[data-volume-level="low"]_.volume-low-icon]:opacity-100',
|
||||
@@ -98,29 +106,29 @@ const styles: MediaDefaultSkinStyles = {
|
||||
VolumeHighIcon: cn('volume-high-icon'),
|
||||
VolumeLowIcon: cn('volume-low-icon'),
|
||||
VolumeOffIcon: cn('volume-off-icon'),
|
||||
FullScreenButton: cn(
|
||||
FullscreenButton: cn(
|
||||
'[&_.fullscreen-enter-icon]:opacity-100 [&[data-fullscreen]_.fullscreen-enter-icon]:opacity-0',
|
||||
'[&_.fullscreen-exit-icon]:opacity-0 [&[data-fullscreen]_.fullscreen-exit-icon]:opacity-100',
|
||||
'[&_path]:transition-transform ease-out',
|
||||
),
|
||||
FullScreenEnterIcon: cn(
|
||||
FullscreenEnterIcon: cn(
|
||||
'fullscreen-enter-icon',
|
||||
'group-hover/button:[&_.arrow-1]:-translate-x-px group-hover/button:[&_.arrow-1]:-translate-y-px',
|
||||
'group-hover/button:[&_.arrow-2]:translate-x-px group-hover/button:[&_.arrow-2]:translate-y-px',
|
||||
),
|
||||
FullScreenExitIcon: cn(
|
||||
FullscreenExitIcon: cn(
|
||||
'fullscreen-exit-icon',
|
||||
'[&_.arrow-1]:-translate-x-px [&_.arrow-1]:-translate-y-px',
|
||||
'[&_.arrow-2]:translate-x-px [&_.arrow-2]:translate-y-px',
|
||||
'group-hover/button:[&_.arrow-1]:translate-0',
|
||||
'group-hover/button:[&_.arrow-2]:translate-0',
|
||||
),
|
||||
FullScreenTooltipPopup: cn(
|
||||
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'),
|
||||
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(
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface MediaDefaultSkinStyles {
|
||||
readonly MediaContainer: string;
|
||||
readonly Overlay: string;
|
||||
readonly Controls: string;
|
||||
readonly Icon: string;
|
||||
readonly Button: string;
|
||||
readonly IconButton: string;
|
||||
readonly PlayButton: string;
|
||||
@@ -15,12 +16,12 @@ export interface MediaDefaultSkinStyles {
|
||||
readonly VolumeHighIcon: string;
|
||||
readonly VolumeLowIcon: string;
|
||||
readonly VolumeOffIcon: string;
|
||||
readonly FullScreenButton: string;
|
||||
readonly FullScreenEnterIcon: string;
|
||||
readonly FullScreenExitIcon: string;
|
||||
readonly FullScreenTooltipPopup: string;
|
||||
readonly FullScreenEnterTooltip: string;
|
||||
readonly FullScreenExitTooltip: string;
|
||||
readonly FullscreenButton: string;
|
||||
readonly FullscreenEnterIcon: string;
|
||||
readonly FullscreenExitIcon: string;
|
||||
readonly FullscreenTooltipPopup: string;
|
||||
readonly FullscreenEnterTooltip: string;
|
||||
readonly FullscreenExitTooltip: string;
|
||||
readonly SliderRoot: string;
|
||||
readonly SliderTrack: string;
|
||||
readonly SliderProgress: string;
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
|
||||
|
||||
<div className={styles.Controls}>
|
||||
<PlayButton className={`${styles.Button} ${styles.IconButton} ${styles.PlayButton}`}>
|
||||
<PlayIcon className={styles.PlayIcon}></PlayIcon>
|
||||
<PauseIcon className={styles.PauseIcon}></PauseIcon>
|
||||
<PlayIcon className={`${styles.PlayIcon} ${styles.Icon}`} />
|
||||
<PauseIcon className={`${styles.PauseIcon} ${styles.Icon}`} />
|
||||
</PlayButton>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -57,14 +57,14 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
|
||||
|
||||
<div className={styles.ButtonGroup}>
|
||||
<MuteButton className={`${styles.Button} ${styles.IconButton} ${styles.MuteButton}`}>
|
||||
<VolumeHighIcon className={styles.VolumeHighIcon} />
|
||||
<VolumeLowIcon className={styles.VolumeLowIcon} />
|
||||
<VolumeOffIcon className={styles.VolumeOffIcon} />
|
||||
<VolumeHighIcon className={`${styles.VolumeHighIcon} ${styles.Icon}`} />
|
||||
<VolumeLowIcon className={`${styles.VolumeLowIcon} ${styles.Icon}`} />
|
||||
<VolumeOffIcon className={`${styles.VolumeOffIcon} ${styles.Icon}`} />
|
||||
</MuteButton>
|
||||
|
||||
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
|
||||
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
|
||||
<FullscreenExitIcon className={styles.FullScreenExitIcon} />
|
||||
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullscreenButton}`}>
|
||||
<FullscreenEnterIcon className={`${styles.FullscreenEnterIcon} ${styles.Icon}`} />
|
||||
<FullscreenExitIcon className={`${styles.FullscreenExitIcon} ${styles.Icon}`} />
|
||||
</FullscreenButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { MediaToastedSkinStyles } from './types';
|
||||
|
||||
import { cn } from '../../utils/cn';
|
||||
// NOTE: Removing import to sidestep for compiler complexity (CJP)
|
||||
// import { cn } from '../../utils/cn';
|
||||
// A (very crude) utility to merge class names
|
||||
// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case.
|
||||
// It just makes the billions of Tailwind classes a little easier to read.
|
||||
function cn(...classes: (string | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const styles: MediaToastedSkinStyles = {
|
||||
MediaContainer: cn(
|
||||
@@ -33,6 +40,7 @@ const styles: MediaToastedSkinStyles = {
|
||||
'has-[[data-paused]]:translate-y-0 has-[[data-paused]]:opacity-100 has-[[data-paused]]:delay-0 has-[[data-paused]]:duration-75',
|
||||
'group-hover/root:translate-y-0 group-hover/root:opacity-100 group-hover/root:delay-0 group-hover/root:duration-75',
|
||||
),
|
||||
Icon: cn('icon'),
|
||||
Button: cn(
|
||||
'group/button cursor-pointer relative shrink-0 transition select-none p-2 rounded-md',
|
||||
// Background/foreground
|
||||
@@ -52,8 +60,8 @@ const styles: MediaToastedSkinStyles = {
|
||||
),
|
||||
ButtonGroup: cn('flex items-center gap-1.5'),
|
||||
IconButton: cn(
|
||||
'grid [&_svg]:[grid-area:1/1]',
|
||||
'[&_svg]:shrink-0 [&_svg]:transition [&_svg]:duration-300 [&_svg]:ease-out [&_svg]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_svg]:shadow-black/20',
|
||||
'grid [&_.icon]:[grid-area:1/1]',
|
||||
'[&_.icon]:shrink-0 [&_.icon]:transition [&_.icon]:duration-300 [&_.icon]:ease-out [&_.icon]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] [&_.icon]:shadow-black/20',
|
||||
),
|
||||
PlayButton: cn(
|
||||
'[&_.pause-icon]:opacity-100 [&[data-paused]_.pause-icon]:opacity-0',
|
||||
@@ -62,7 +70,7 @@ const styles: MediaToastedSkinStyles = {
|
||||
PlayIcon: cn('play-icon'),
|
||||
PauseIcon: cn('pause-icon'),
|
||||
MuteButton: cn(
|
||||
'[&_svg]:hidden',
|
||||
'[&_.icon]:hidden',
|
||||
'[&[data-volume-level="high"]_.volume-high-icon]:inline',
|
||||
'[&[data-volume-level="medium"]_.volume-low-icon]:inline',
|
||||
'[&[data-volume-level="low"]_.volume-low-icon]:inline',
|
||||
@@ -71,17 +79,17 @@ const styles: MediaToastedSkinStyles = {
|
||||
VolumeHighIcon: cn('volume-high-icon'),
|
||||
VolumeLowIcon: cn('volume-low-icon'),
|
||||
VolumeOffIcon: cn('volume-off-icon'),
|
||||
FullScreenButton: cn(
|
||||
FullscreenButton: cn(
|
||||
'[&_.fullscreen-enter-icon]:opacity-100 [&[data-fullscreen]_.fullscreen-enter-icon]:opacity-0',
|
||||
'[&_.fullscreen-exit-icon]:opacity-0 [&[data-fullscreen]_.fullscreen-exit-icon]:opacity-100',
|
||||
'[&_path]:transition-transform ease-out',
|
||||
),
|
||||
FullScreenEnterIcon: cn(
|
||||
FullscreenEnterIcon: cn(
|
||||
'fullscreen-enter-icon',
|
||||
'group-hover/button:[&_.arrow-1]:-translate-x-px group-hover/button:[&_.arrow-1]:-translate-y-px',
|
||||
'group-hover/button:[&_.arrow-2]:translate-x-px group-hover/button:[&_.arrow-2]:translate-y-px',
|
||||
),
|
||||
FullScreenExitIcon: cn(
|
||||
FullscreenExitIcon: cn(
|
||||
'fullscreen-exit-icon',
|
||||
'[&_.arrow-1]:-translate-x-px [&_.arrow-1]:-translate-y-px',
|
||||
'[&_.arrow-2]:translate-x-px [&_.arrow-2]:translate-y-px',
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface MediaToastedSkinStyles {
|
||||
readonly MediaContainer: string;
|
||||
readonly Overlay: string;
|
||||
readonly Controls: string;
|
||||
readonly Icon: string;
|
||||
readonly Button: string;
|
||||
readonly ButtonGroup: string;
|
||||
readonly IconButton: string;
|
||||
@@ -12,9 +13,9 @@ export interface MediaToastedSkinStyles {
|
||||
readonly VolumeHighIcon: string;
|
||||
readonly VolumeLowIcon: string;
|
||||
readonly VolumeOffIcon: string;
|
||||
readonly FullScreenButton: string;
|
||||
readonly FullScreenEnterIcon: string;
|
||||
readonly FullScreenExitIcon: string;
|
||||
readonly FullscreenButton: string;
|
||||
readonly FullscreenEnterIcon: string;
|
||||
readonly FullscreenExitIcon: string;
|
||||
readonly TimeSliderRoot: string;
|
||||
readonly TimeSliderThumb: string;
|
||||
readonly TimeDisplay: string;
|
||||
|
||||
Generated
+3
@@ -81,6 +81,9 @@ importers:
|
||||
'@vjs-10/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/react/react
|
||||
'@vjs-10/react-icons':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/react/react-icons
|
||||
react:
|
||||
specifier: ^18.0.0
|
||||
version: 18.3.1
|
||||
|
||||
Reference in New Issue
Block a user