fix(skin): improve buffering, overlays, and input feedback (#1547)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sam Potts
2026-07-01 09:49:12 +10:00
committed by GitHub
co-authored by Cursor
parent 7c3be22e8a
commit 0de3fef878
55 changed files with 864 additions and 544 deletions
@@ -8,6 +8,7 @@ import {
iconFlipped,
iconState,
menu,
playButton,
playbackRate,
popup,
root,
@@ -23,11 +24,13 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Menu } from '@/ui/menu';
import { MuteButton } from '@/ui/mute-button';
@@ -179,21 +182,30 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
<div className={controls}>
<Tooltip.Provider>
<div className={buttonGroup}>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={iconState.play.button} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
<span className={playButton.wrapper}>
<BufferingIndicator
render={(props) => (
<div {...props} className={cn(playButton.bufferingRoot, props.className)}>
<SpinnerIcon className={icon} />
</div>
)}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={cn(iconState.play.button, playButton.control)} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
@@ -6,11 +6,13 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { Menu } from '@/ui/menu';
@@ -126,21 +128,30 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
<div className="media-controls">
<Tooltip.Provider>
<div className="media-button-group">
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
<span className="media-button--play__wrapper">
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
)}
/>
<Tooltip.Popup className="media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
@@ -8,6 +8,7 @@ import {
iconFlipped,
iconState,
menu,
playButton,
playbackRate,
popup,
root,
@@ -23,11 +24,13 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { Menu } from '@/ui/menu';
@@ -181,21 +184,30 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
<div className={controls}>
<Tooltip.Provider>
<div className={buttonGroup}>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={iconState.play.button} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
<span className={playButton.wrapper}>
<BufferingIndicator
render={(props) => (
<div {...props} className={cn(playButton.bufferingRoot, props.className)}>
<SpinnerIcon className={icon} />
</div>
)}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={cn(iconState.play.button, playButton.control)} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
+25 -14
View File
@@ -6,11 +6,13 @@ import {
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { Menu } from '@/ui/menu';
@@ -126,21 +128,30 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
<div className="media-surface media-controls">
<Tooltip.Provider>
<div className="media-button-group">
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
<span className="media-button--play__wrapper">
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
)}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
@@ -5,14 +5,24 @@ import {
error,
icon,
iconState,
playButton,
popup,
root,
slider,
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal';
import {
PauseIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { LiveButton } from '@/ui/live-button';
@@ -119,21 +129,30 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps):
<div className={controls}>
<Tooltip.Provider>
<div className={buttonGroup}>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={iconState.play.button} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
<span className={playButton.wrapper}>
<BufferingIndicator
render={(props) => (
<div {...props} className={cn(playButton.bufferingRoot, props.className)}>
<SpinnerIcon className={icon} />
</div>
)}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={cn(iconState.play.button, playButton.control)} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<LiveButton className={cn(button.base, button.subtle, button.live)} />
</div>
@@ -1,7 +1,16 @@
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal';
import {
PauseIcon,
PlayIcon,
RestartIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@/icons/minimal';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { LiveButton } from '@/ui/live-button';
@@ -85,21 +94,30 @@ export function MinimalLiveAudioSkin(props: MinimalLiveAudioSkinProps): ReactNod
<div className="media-controls">
<Tooltip.Provider>
<div className="media-button-group">
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
<span className="media-button--play__wrapper">
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
)}
/>
<Tooltip.Popup className="media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<Tooltip.Label />
<Tooltip.Shortcut className="media-tooltip__kbd" />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<LiveButton className="media-button media-button--subtle media-button--live" />
</div>
@@ -5,14 +5,16 @@ import {
error,
icon,
iconState,
playButton,
popup,
root,
slider,
} from '@videojs/skins/default/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { PauseIcon, PlayIcon, RestartIcon, SpinnerIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { LiveButton } from '@/ui/live-button';
@@ -119,21 +121,30 @@ export function LiveAudioSkinTailwind(props: LiveAudioSkinProps): ReactNode {
<div className={controls}>
<Tooltip.Provider>
<div className={buttonGroup}>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={iconState.play.button} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
<span className={playButton.wrapper}>
<BufferingIndicator
render={(props) => (
<div {...props} className={cn(playButton.bufferingRoot, props.className)}>
<SpinnerIcon className={icon} />
</div>
)}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className={cn(iconState.play.button, playButton.control)} render={<Button />}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
</span>
<LiveButton className={cn(button.base, button.subtle, button.live)} />
</div>
+22 -12
View File
@@ -1,7 +1,8 @@
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { PauseIcon, PlayIcon, RestartIcon, SpinnerIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons';
import { Container, usePlayer } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { ErrorDialog } from '@/ui/error-dialog';
import { Hotkey } from '@/ui/hotkey';
import { LiveButton } from '@/ui/live-button';
@@ -93,18 +94,27 @@ export function LiveAudioSkin(props: LiveAudioSkinProps): ReactNode {
<div className="media-surface media-controls">
<Tooltip.Provider>
<div className="media-button-group">
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
<span className="media-button--play__wrapper">
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<SpinnerIcon className="media-icon" />
</div>
)}
/>
<TooltipPopup />
</Tooltip.Root>
<Tooltip.Root side="top" boundary="viewport">
<Tooltip.Trigger
render={
<PlayButton className="media-button--play" render={<Button />}>
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</PlayButton>
}
/>
<TooltipPopup />
</Tooltip.Root>
</span>
<LiveButton className="media-button media-button--subtle media-button--live" />
</div>
@@ -217,9 +217,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode {
<BufferingIndicator
render={(props) => (
<div {...props} className={bufferingIndicator.root}>
<div className={bufferingIndicator.container}>
<SpinnerIcon className={icon} />
</div>
<SpinnerIcon className={icon} />
</div>
)}
/>
@@ -178,9 +178,7 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode {
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<div className="media-surface">
<SpinnerIcon className="media-icon" />
</div>
<SpinnerIcon className="media-icon" />
</div>
)}
/>
@@ -425,9 +425,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<BufferingIndicator
render={(props) => (
<div {...props} className={bufferingIndicator.root}>
<div className={bufferingIndicator.container}>
<SpinnerIcon className={icon} />
</div>
<SpinnerIcon className={icon} />
</div>
)}
/>
+1 -3
View File
@@ -360,9 +360,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
<div className="media-surface">
<SpinnerIcon className="media-icon" />
</div>
<SpinnerIcon className="media-icon" />
</div>
)}
/>
@@ -0,0 +1,99 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { IndicatorLifecycleState, TransitionState } from '@videojs/core';
import { createState } from '@videojs/store';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const transitionMock = vi.hoisted(() => ({
createTransition: vi.fn(),
}));
vi.mock('@videojs/core/dom', async (importOriginal) => ({
...(await importOriginal<typeof import('@videojs/core/dom')>()),
createTransition: transitionMock.createTransition,
}));
import { useRenderedIndicatorState } from '../use-rendered-indicator-state';
interface TestIndicatorState extends IndicatorLifecycleState {
value: string;
}
function makeState(generation: number): TestIndicatorState {
return {
open: true,
generation,
value: String(generation),
transitionStarting: false,
transitionEnding: false,
};
}
function mockTransition() {
const state = createState<TransitionState>({ active: false, status: 'idle' });
const open = vi.fn(() => {
state.patch({ active: true, status: 'starting' });
return Promise.resolve();
});
const close = vi.fn(() => Promise.resolve());
const cancel = vi.fn();
const destroy = vi.fn();
transitionMock.createTransition.mockReturnValue({ state, open, close, cancel, destroy });
return { state, open, close, cancel, destroy };
}
beforeEach(() => {
transitionMock.createTransition.mockReset();
});
describe('useRenderedIndicatorState', () => {
it('replays the open transition on updates by default', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
rerender({ current: makeState(2) });
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(2);
});
});
it('skips replaying the open transition on updates when disabled', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
rerender({ current: makeState(2) });
expect(transition.open).toHaveBeenCalledTimes(1);
});
it('cancels an ending transition when replay is disabled and an update arrives', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
transition.state.patch({ active: true, status: 'ending' });
rerender({ current: makeState(2) });
expect(transition.open).toHaveBeenCalledTimes(1);
expect(transition.cancel).toHaveBeenCalledOnce();
});
});
@@ -7,7 +7,7 @@ import { useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
import { useIndicatorVisibility } from './use-indicator-visibility';
import { useInputActionSubscription } from './use-input-action-subscription';
import { useRenderedIndicatorState } from './use-rendered-indicator-state';
import { type RenderedIndicatorOptions, useRenderedIndicatorState } from './use-rendered-indicator-state';
interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState, Props> {
readonly state: StoreState<IndicatorState>;
@@ -19,7 +19,8 @@ interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState,
export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleState, Props>(
createCore: () => InputIndicatorRootCore<IndicatorState, Props>,
props: Props
props: Props,
options?: RenderedIndicatorOptions
) {
const [core] = useState(createCore);
useDestroy(core);
@@ -36,5 +37,5 @@ export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleS
() => core.state.current
);
return useRenderedIndicatorState(currentState);
return useRenderedIndicatorState(currentState, options);
}
@@ -2,11 +2,18 @@
import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(currentState: State) {
export interface RenderedIndicatorOptions {
replayOnUpdate?: boolean | undefined;
}
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(
currentState: State,
options: RenderedIndicatorOptions = {}
) {
const elementRef = useRef<HTMLElement>(null);
const currentStateRef = useRef(currentState);
const snapshotRef = useRef(currentState);
@@ -22,13 +29,18 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
const { generation, open } = currentState;
useEffect(() => {
useLayoutEffect(() => {
if (open) {
const nextState = currentStateRef.current;
if (nextState.generation !== generation) return;
snapshotRef.current = nextState;
void transition.open();
const transitionState = transition.state.current;
if (!transitionState.active || options.replayOnUpdate !== false) {
void transition.open(elementRef.current);
} else if (transitionState.status === 'ending') {
transition.cancel();
}
return;
}
@@ -36,7 +48,7 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
if (active && status !== 'ending') {
void transition.close(elementRef.current);
}
}, [generation, open, transition]);
}, [generation, open, options.replayOnUpdate, transition]);
return {
elementRef,
@@ -18,7 +18,11 @@ export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot(
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, closeDelay, ...elementProps } = componentProps;
const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay });
const { elementRef, present, state } = useInputIndicatorRoot(
() => new VolumeIndicatorCore(),
{ closeDelay },
{ replayOnUpdate: false }
);
if (!present) return null;