feat(react): port volume popover and slider styling into skin presets (#667)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-03-02 10:27:53 +11:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ebb75f5863
commit 6a1eddadd0
9 changed files with 415 additions and 99 deletions
+11 -1
View File
@@ -60,7 +60,17 @@
"noUnusedVariables": "warn",
"noUnusedImports": "warn",
"noUnusedFunctionParameters": "off",
"useExhaustiveDependencies": "warn"
"useExhaustiveDependencies": {
"level": "warn",
"options": {
"hooks": [
{
"name": "useForceRender",
"stableResult": true
}
]
}
}
},
"a11y": {
"noSvgWithoutTitle": "warn"
@@ -467,8 +467,6 @@
/* Thumb */
.media-minimal-skin .media-slider__thumb {
position: absolute;
top: 50%;
left: var(--media-slider-fill, 0%);
transform: translate(-50%, -50%);
z-index: 10;
width: 0.75rem;
@@ -489,12 +487,21 @@
outline: 2px solid transparent;
outline-offset: -2px;
}
.media-minimal-skin .media-slider__thumb[data-orientation="horizontal"] {
top: 50%;
left: var(--media-slider-fill);
}
.media-minimal-skin .media-slider__thumb[data-orientation="vertical"] {
left: 50%;
top: calc(100% - var(--media-slider-fill));
}
.media-minimal-skin .media-slider__thumb:focus-visible {
outline-color: oklch(1 0 0);
outline-offset: 2px;
}
.media-minimal-skin .media-slider:hover .media-slider__thumb,
.media-minimal-skin .media-slider:focus-within .media-slider__thumb {
.media-minimal-skin .media-slider:focus-within .media-slider__thumb,
.media-minimal-skin .media-slider__thumb--persistent {
opacity: 1;
scale: 1;
}
@@ -503,11 +510,19 @@
.media-minimal-skin .media-slider__buffer,
.media-minimal-skin .media-slider__fill {
position: absolute;
inset-block: 0;
left: 0;
border-radius: inherit;
pointer-events: none;
}
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"],
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
inset-block: 0;
left: 0;
}
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"],
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
inset-inline: 0;
bottom: 0;
}
/* Buffer */
.media-minimal-skin .media-slider__buffer {
@@ -519,7 +534,12 @@
/* Fill */
.media-minimal-skin .media-slider__fill {
background-color: oklch(1 0 0);
width: var(--media-slider-fill, 0%);
}
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
width: var(--media-slider-fill);
}
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
height: var(--media-slider-fill);
}
/* Time display within slider */
@@ -549,11 +569,14 @@
transition-duration: 0ms;
}
.media-minimal-skin .media-popover-popup {
.media-minimal-skin .media-popup {
margin: 0;
padding: 0.5rem;
border: 0;
background: transparent;
--media-popover-side-offset: 0.5rem;
}
.media-minimal-skin .media-popup--volume {
padding: 0.25rem;
}
/* ==========================================================================
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { MinimalVideoSkinProps } from './minimal-skin';
const SEEK_TIME = 10;
@@ -123,6 +125,94 @@ function FullscreenButtonIcon({ state, className, ...rest }: { state: Fullscreen
);
}
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
// Vertical
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-[4.5rem]',
className
)}
{...props}
/>
);
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
// Horizontal
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-0.75',
// Vertical
'data-[orientation=vertical]:w-0.75 data-[orientation=vertical]:h-full',
className
)}
{...props}
/>
);
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderFill({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'absolute rounded-[inherit] pointer-events-none bg-white',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:w-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0 data-[orientation=vertical]:h-(--media-slider-fill)',
className
)}
{...props}
/>
);
});
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
{ persistent, className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
'bg-white rounded-full',
'ring ring-black/10 shadow-sm shadow-black/15',
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-white focus-visible:outline-offset-2',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
// Visibility
persistent
? undefined
: cn(
'opacity-0 scale-70 origin-center',
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
),
className
)}
{...props}
/>
);
});
/* ------------------------------------------ Skin ------------------------------------------- */
export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode {
@@ -294,34 +384,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
/>
</Time.Group>
<TimeSlider.Root
className={cn(
'group/slider relative flex items-center justify-center flex-1 rounded-full outline-none',
'min-w-20 w-full h-5'
)}
>
<TimeSlider.Track
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
'w-full h-0.75'
)}
>
<TimeSlider.Fill className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white w-(--media-slider-fill)" />
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
<TimeSlider.Buffer className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white/20 w-(--media-slider-buffer) transition-[width] duration-250 ease-out" />
</TimeSlider.Track>
<TimeSlider.Thumb
className={cn(
'z-10 absolute top-1/2 left-(--media-slider-fill) -translate-x-1/2 -translate-y-1/2',
'size-3 bg-white rounded-full origin-center',
'ring ring-black/10 shadow-sm shadow-black/15',
'opacity-0 scale-70 transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-white focus-visible:outline-offset-2',
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
)}
/>
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
</TimeSlider.Root>
</span>
@@ -334,13 +402,44 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
)}
/>
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
/>
}
/>
<Popover.Popup
className={cn(
// Reset & offset
'm-0 border-0 bg-transparent [--media-popover-side-offset:0.5rem]',
// Volume popup
'p-1',
// Animation
'opacity-100 scale-100 origin-bottom blur-none',
'transition-[transform,scale,opacity,filter] duration-200',
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
'data-[instant]:duration-0'
)}
>
<VolumeSlider.Root
orientation="vertical"
thumbAlignment="edge"
render={(props) => <SliderRoot {...props} />}
>
<VolumeSlider.Track render={(props) => <SliderTrack {...props} />}>
<VolumeSlider.Fill render={(props) => <SliderFill {...props} />} />
</VolumeSlider.Track>
<VolumeSlider.Thumb render={(props) => <SliderThumb persistent {...props} />} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<CaptionsButton
render={(props, state) => (
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { BaseSkinProps } from '../types';
const SEEK_TIME = 10;
@@ -178,13 +180,28 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
)}
/>
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
{...props}
/>
}
/>
<Popover.Popup className="media-surface media-popup media-popup--volume media-popup-animation">
<VolumeSlider.Root className="media-slider" orientation="vertical" thumbAlignment="edge">
<VolumeSlider.Track className="media-slider__track">
<VolumeSlider.Fill className="media-slider__fill" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="media-slider__thumb media-slider__thumb--persistent" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<CaptionsButton
render={(props, state) => (
+38 -9
View File
@@ -482,8 +482,6 @@
.media-default-skin .media-slider__thumb {
z-index: 10;
position: absolute;
top: 50%;
left: var(--media-slider-fill);
transform: translate(-50%, -50%);
width: 0.625rem;
height: 0.625rem;
@@ -501,16 +499,26 @@
outline: 2px solid transparent;
outline-offset: -2px;
}
.media-default-skin .media-slider__thumb[data-orientation="horizontal"] {
top: 50%;
left: var(--media-slider-fill);
}
.media-default-skin .media-slider__thumb[data-orientation="vertical"] {
left: 50%;
top: calc(100% - var(--media-slider-fill));
}
.media-default-skin .media-slider__thumb:focus-visible {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 2px;
}
.media-default-skin .media-slider:active .media-slider__thumb {
.media-default-skin .media-slider:active .media-slider__thumb,
.media-default-skin .media-slider__thumb--persistent {
width: 0.75rem;
height: 0.75rem;
}
.media-default-skin .media-slider:hover .media-slider__thumb,
.media-default-skin .media-slider__thumb:focus-visible {
.media-default-skin .media-slider__thumb:focus-visible,
.media-default-skin .media-slider__thumb--persistent {
opacity: 1;
}
@@ -518,24 +526,42 @@
.media-default-skin .media-slider__buffer,
.media-default-skin .media-slider__fill {
position: absolute;
inset-block: 0;
left: 0;
border-radius: inherit;
pointer-events: none;
}
.media-default-skin .media-slider__buffer[data-orientation="horizontal"],
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
inset-block: 0;
left: 0;
}
.media-default-skin .media-slider__buffer[data-orientation="vertical"],
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
inset-inline: 0;
bottom: 0;
}
/* Buffer */
.media-default-skin .media-slider__buffer {
background-color: oklch(1 0 0 / 0.2);
width: var(--media-slider-buffer);
transition: width 0.25s ease-out;
}
.media-default-skin .media-slider__buffer[data-orientation="horizontal"] {
width: var(--media-slider-buffer);
}
.media-default-skin .media-slider__buffer[data-orientation="vertical"] {
height: var(--media-slider-buffer);
}
/* Fill */
.media-default-skin .media-slider__fill {
background-color: oklch(1 0 0);
}
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
width: var(--media-slider-fill);
}
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
height: var(--media-slider-fill);
}
/* Time display within slider */
.media-default-skin .media-slider__time-display {
@@ -564,10 +590,13 @@
transition-duration: 0ms;
}
.media-default-skin .media-popover-popup {
.media-default-skin .media-popup {
margin: 0;
padding: 0.75rem 0.25rem;
border: 0;
--media-popover-side-offset: 0.5rem;
}
.media-default-skin .media-popup--volume {
padding: 0.625rem 0.25rem;
border-radius: calc(infinity * 1px);
}
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { VideoSkinProps } from './skin';
const SEEK_TIME = 10;
@@ -137,6 +139,94 @@ function FullscreenButtonIcon({ state, className, ...rest }: { state: Fullscreen
);
}
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
// Horizontal
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
// Vertical
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-20',
className
)}
{...props}
/>
);
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
// Horizontal
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
// Vertical
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full',
className
)}
{...props}
/>
);
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderFill({ className, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'absolute rounded-[inherit] pointer-events-none bg-white',
// Horizontal
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:w-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0 data-[orientation=vertical]:h-(--media-slider-fill)',
className
)}
{...props}
/>
);
});
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
{ persistent, className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
'bg-white rounded-full',
'ring ring-black/10 shadow-sm shadow-black/15',
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
// Horizontal
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
// Vertical
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
// Visibility
persistent
? 'size-3'
: cn(
'size-2.5',
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
'group-active/slider:size-3'
),
className
)}
{...props}
/>
);
});
/* ------------------------------------------ Skin ------------------------------------------- */
export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
@@ -301,34 +391,12 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
type="current"
className="hidden @2xs/media-time:block text-shadow-2xs text-shadow-black/25 tabular-nums"
/>
<TimeSlider.Root
className={cn(
'group/slider relative flex items-center justify-center flex-1 rounded-full outline-none',
'min-w-20 w-full h-5'
)}
>
<TimeSlider.Track
className={cn(
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
'w-full h-1'
)}
>
<TimeSlider.Fill className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white w-(--media-slider-fill)" />
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
<TimeSlider.Buffer className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white/20 w-(--media-slider-buffer) transition-[width] duration-250 ease-out" />
</TimeSlider.Track>
<TimeSlider.Thumb
className={cn(
'z-10 absolute top-1/2 left-(--media-slider-fill) -translate-x-1/2 -translate-y-1/2',
'size-2.5 bg-white rounded-full',
'ring ring-black/10 shadow-sm shadow-black/15',
'opacity-0 transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
'outline-2 outline-transparent -outline-offset-2',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2 focus-visible:opacity-100',
'group-hover/slider:opacity-100',
'group-active/slider:size-3'
)}
/>
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
</TimeSlider.Root>
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
</Time.Group>
@@ -341,13 +409,46 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
)}
/>
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
/>
}
/>
<Popover.Popup
className={cn(
// Reset & offset
'm-0 border-0 [--media-popover-side-offset:0.5rem]',
// Volume popup
'py-2.5 px-1 rounded-full',
// Surface
surface,
// Animation
'opacity-100 scale-100 origin-bottom blur-none',
'transition-[transform,scale,opacity,filter] duration-200',
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
'data-[instant]:duration-0'
)}
>
<VolumeSlider.Root
orientation="vertical"
thumbAlignment="edge"
render={(props) => <SliderRoot {...props} />}
>
<VolumeSlider.Track render={(props) => <SliderTrack {...props} />}>
<VolumeSlider.Fill render={(props) => <SliderFill {...props} />} />
</VolumeSlider.Track>
<VolumeSlider.Thumb render={(props) => <SliderThumb persistent {...props} />} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<CaptionsButton
render={(props, state) => (
+24 -7
View File
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { BaseSkinProps } from '../types';
const SEEK_TIME = 10;
@@ -173,13 +175,28 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
)}
/>
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props, state) => (
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
{...props}
/>
}
/>
<Popover.Popup className="media-surface media-popup media-popup--volume media-popup-animation">
<VolumeSlider.Root className="media-slider" orientation="vertical" thumbAlignment="edge">
<VolumeSlider.Track className="media-slider__track">
<VolumeSlider.Fill className="media-slider__fill" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="media-slider__thumb media-slider__thumb--persistent" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<CaptionsButton
render={(props, state) => (
+11 -1
View File
@@ -10,7 +10,8 @@ import {
} from '@videojs/core/dom';
import { useSnapshot } from '@videojs/store/react';
import { isRTL } from '@videojs/utils/dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useForceRender } from '@/utils/use-force-render';
import { useLatestRef } from '../../utils/use-latest-ref';
export interface UseSliderOptions<State extends SliderState = SliderState>
@@ -57,6 +58,7 @@ export function useSlider<State extends SliderState = SliderState>(
const rootElementRef = useRef<HTMLElement | null>(null);
const thumbElementRef = useRef<HTMLElement | null>(null);
const forceRender = useForceRender();
// Lazy-init the slider handle. Stable across re-renders.
const [slider] = useState<SliderHandle>(() => {
@@ -82,6 +84,14 @@ export function useSlider<State extends SliderState = SliderState>(
// Cleanup on unmount.
useEffect(() => () => slider.destroy(), [slider]);
// Force a synchronous re-render after mount so edge thumb alignment
// can read DOM measurements from the now-populated element refs.
useLayoutEffect(() => {
if (rootElementRef.current && thumbElementRef.current) {
forceRender();
}
}, []);
// Subscribe to interaction state.
const interaction = useSnapshot(slider.interaction);
@@ -0,0 +1,10 @@
import { useReducer } from 'react';
export function useForceRender() {
const [, forceRender] = useReducer((c: number) => c + 1, 0);
return forceRender;
}
export namespace useForceRender {
export type Result = () => void;
}