mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add time slider component (#647)
This commit is contained in:
@@ -51,6 +51,8 @@ export type { SliderTrackProps } from './ui/slider/slider-track';
|
||||
export type { SliderValueProps } from './ui/slider/slider-value';
|
||||
export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail';
|
||||
export { Time } from './ui/time';
|
||||
export { TimeSlider } from './ui/time-slider';
|
||||
export { VolumeSlider } from './ui/volume-slider';
|
||||
|
||||
// Utilities
|
||||
export { mergeProps } from './utils/merge-props';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
Buffer,
|
||||
type BufferProps,
|
||||
Fill,
|
||||
type FillProps,
|
||||
Thumb,
|
||||
type ThumbProps,
|
||||
Track,
|
||||
type TrackProps,
|
||||
Value,
|
||||
type ValueProps,
|
||||
} from '../slider/index.parts';
|
||||
export { TimeSliderRoot as Root, type TimeSliderRootProps as RootProps } from './time-slider-root';
|
||||
@@ -0,0 +1 @@
|
||||
export * as TimeSlider from './index.parts';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createPlayerWrapper } from '../../../testing/mocks';
|
||||
import { SliderBuffer } from '../../slider/slider-buffer';
|
||||
import { SliderFill } from '../../slider/slider-fill';
|
||||
import { SliderThumb } from '../../slider/slider-thumb';
|
||||
import { SliderTrack } from '../../slider/slider-track';
|
||||
import { SliderValue } from '../../slider/slider-value';
|
||||
import { TimeSliderRoot } from '../time-slider-root';
|
||||
|
||||
// --- Hoisted mock data (available inside vi.mock factories) ---
|
||||
|
||||
const { mockSliderHandle, mockTimeState, mockBufferState } = vi.hoisted(() => ({
|
||||
mockSliderHandle: () => ({
|
||||
interaction: {
|
||||
current: {
|
||||
pointerPercent: 0,
|
||||
dragPercent: 0,
|
||||
dragging: false,
|
||||
pointing: false,
|
||||
focused: false,
|
||||
},
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
rootProps: {
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerLeave: vi.fn(),
|
||||
},
|
||||
thumbProps: {
|
||||
onKeyDown: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onBlur: vi.fn(),
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
mockTimeState: {
|
||||
currentTime: 30,
|
||||
duration: 120,
|
||||
seeking: false,
|
||||
seek: vi.fn(),
|
||||
},
|
||||
mockBufferState: {
|
||||
buffered: [[0, 60]] as [number, number][],
|
||||
seekable: [[0, 120]] as [number, number][],
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Module mocks ---
|
||||
|
||||
vi.mock('@videojs/core/dom', async (importOriginal) => {
|
||||
const orig: Record<string, unknown> = await importOriginal();
|
||||
return { ...orig, createSlider: vi.fn(mockSliderHandle) };
|
||||
});
|
||||
|
||||
vi.mock('@videojs/store/react', () => ({
|
||||
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
|
||||
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
|
||||
if (!selector) return _store;
|
||||
try {
|
||||
const result = selector({ time: mockTimeState, buffer: mockBufferState });
|
||||
if (result !== undefined) return result;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
return selector({ ...mockTimeState, ...mockBufferState });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('TimeSliderRoot', () => {
|
||||
it('renders a div element', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
const el = container.querySelector('div > div');
|
||||
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('forwards ref to the root element', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot ref={ref} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('spreads additional props', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot data-testid="time-slider" />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="time-slider"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets data-orientation to horizontal', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-orientation]');
|
||||
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('sets slider CSS custom properties', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-orientation]') as HTMLElement;
|
||||
expect(el?.style.getPropertyValue('--media-slider-fill')).toBeTruthy();
|
||||
expect(el?.style.getPropertyValue('--media-slider-pointer')).toBeTruthy();
|
||||
expect(el?.style.getPropertyValue('--media-slider-buffer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeSlider compound', () => {
|
||||
it('renders all parts together', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot data-testid="root">
|
||||
<SliderTrack data-testid="track">
|
||||
<SliderFill data-testid="fill" />
|
||||
<SliderBuffer data-testid="buffer" />
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</SliderTrack>
|
||||
<SliderValue data-testid="value" />
|
||||
</TimeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="value"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('thumb receives ARIA attributes from TimeSliderCore', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot>
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</TimeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const thumb = container.querySelector('[data-testid="thumb"]');
|
||||
expect(thumb?.getAttribute('role')).toBe('slider');
|
||||
expect(thumb?.getAttribute('aria-label')).toBe('Seek');
|
||||
});
|
||||
|
||||
it('SliderValue displays formatted time', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<TimeSliderRoot>
|
||||
<SliderValue data-testid="value" />
|
||||
</TimeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const output = container.querySelector('[data-testid="value"]');
|
||||
expect(output?.textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core';
|
||||
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectTime } from '@videojs/core/dom';
|
||||
import { formatTime } from '@videojs/utils/time';
|
||||
import { forwardRef, useRef, useState } from 'react';
|
||||
|
||||
import { usePlayer } from '../../player/context';
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { useLatestRef } from '../../utils/use-latest-ref';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSlider } from '../hooks/use-slider';
|
||||
import { SliderProvider } from '../slider/slider-context';
|
||||
|
||||
const noopSeek = (): Promise<number> => Promise.resolve(0);
|
||||
|
||||
export interface TimeSliderRootProps
|
||||
extends UIComponentProps<'div', TimeSliderCore.State>,
|
||||
Pick<TimeSliderCore.Props, 'label' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
|
||||
/** Trailing-edge throttle (ms) for seek requests during drag. Default `100`. `0` disables. */
|
||||
commitThrottle?: number | undefined;
|
||||
onDragStart?: (() => void) | undefined;
|
||||
onDragEnd?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
function TimeSliderRoot(componentProps, forwardedRef) {
|
||||
const {
|
||||
render,
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
step = 1,
|
||||
largeStep = 10,
|
||||
disabled,
|
||||
thumbAlignment,
|
||||
commitThrottle = 100,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
...elementProps
|
||||
} = componentProps;
|
||||
|
||||
const time = usePlayer(selectTime);
|
||||
const buffer = usePlayer(selectBuffer);
|
||||
|
||||
const [core] = useState(() => new TimeSliderCore());
|
||||
core.setProps({ label, step, largeStep, disabled, thumbAlignment });
|
||||
|
||||
// Keep a ref to the latest media state for callbacks that fire outside the render cycle.
|
||||
const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null);
|
||||
|
||||
// Holds the target time (seconds) between commit and seek completion, preventing the slider
|
||||
// from snapping back to the stale `currentTime` while the async seek settles.
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
const duration = time?.duration ?? 0;
|
||||
const range = duration || 1;
|
||||
|
||||
const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider<TimeSliderCore.State>({
|
||||
computeState: (interaction) => {
|
||||
if (!time || !buffer) {
|
||||
return core.getTimeState(
|
||||
{ currentTime: 0, duration: 0, seeking: false, seek: noopSeek, buffered: [], seekable: [] },
|
||||
interaction
|
||||
);
|
||||
}
|
||||
|
||||
const baseState = core.getTimeState({ ...time, ...buffer }, interaction);
|
||||
|
||||
// After drag release, `dragging` resets before the async seek completes. Hold the
|
||||
// slider at the committed position until `currentTime` catches up.
|
||||
const pending = pendingSeekRef.current;
|
||||
if (!interaction.dragging && pending !== null) {
|
||||
if (Math.abs(time.currentTime - pending) < 0.5) {
|
||||
// Seek landed — clear pending.
|
||||
pendingSeekRef.current = null;
|
||||
} else {
|
||||
const dur = time.duration || 1;
|
||||
const fillPercent = (pending / dur) * 100;
|
||||
return { ...baseState, value: pending, fillPercent };
|
||||
}
|
||||
}
|
||||
|
||||
return baseState;
|
||||
},
|
||||
getPercent: () => (duration > 0 ? ((time?.currentTime ?? 0) / duration) * 100 : 0),
|
||||
getStepPercent: () => (step / range) * 100,
|
||||
getLargeStepPercent: () => (largeStep / range) * 100,
|
||||
orientation: 'horizontal',
|
||||
disabled,
|
||||
commitThrottle,
|
||||
adjustPercent: (rawPercent, thumbSize, trackSize) =>
|
||||
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
|
||||
getCSSVars: getTimeSliderCSSVars,
|
||||
onValueChange: (percent) => {
|
||||
// Track the target position for visual hold during pointer interaction.
|
||||
const media = mediaRef.current;
|
||||
if (media) {
|
||||
pendingSeekRef.current = (percent / 100) * (media.duration || 0);
|
||||
}
|
||||
},
|
||||
onValueCommit: (percent) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
const seconds = (percent / 100) * (media.duration || 0);
|
||||
pendingSeekRef.current = seconds;
|
||||
// seek() is async — catch rejection if the media target isn't attached yet.
|
||||
media.seek(seconds).catch(() => {
|
||||
pendingSeekRef.current = null;
|
||||
});
|
||||
},
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
});
|
||||
|
||||
if (!time) {
|
||||
if (__DEV__) logMissingFeature('TimeSlider', 'time');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SliderProvider
|
||||
value={{
|
||||
state,
|
||||
pointerValue: core.valueFromPercent(state.pointerPercent),
|
||||
thumbRef,
|
||||
thumbProps,
|
||||
stateAttrMap: TimeSliderDataAttrs,
|
||||
getAttrs: (sliderState) => core.getAttrs(sliderState as TimeSliderCore.State),
|
||||
formatValue: (value) => formatTime(value, duration),
|
||||
}}
|
||||
>
|
||||
{renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: TimeSliderDataAttrs,
|
||||
ref: [forwardedRef, rootRef],
|
||||
props: [{ style: cssVars }, rootProps, elementProps],
|
||||
}
|
||||
)}
|
||||
</SliderProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export namespace TimeSliderRoot {
|
||||
export type Props = TimeSliderRootProps;
|
||||
export type State = TimeSliderCore.State;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
Fill,
|
||||
type FillProps,
|
||||
Thumb,
|
||||
type ThumbProps,
|
||||
Track,
|
||||
type TrackProps,
|
||||
Value,
|
||||
type ValueProps,
|
||||
} from '../slider/index.parts';
|
||||
export { VolumeSliderRoot as Root, type VolumeSliderRootProps as RootProps } from './volume-slider-root';
|
||||
@@ -0,0 +1 @@
|
||||
export * as VolumeSlider from './index.parts';
|
||||
@@ -0,0 +1,189 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createPlayerWrapper } from '../../../testing/mocks';
|
||||
import { SliderFill } from '../../slider/slider-fill';
|
||||
import { SliderThumb } from '../../slider/slider-thumb';
|
||||
import { SliderTrack } from '../../slider/slider-track';
|
||||
import { SliderValue } from '../../slider/slider-value';
|
||||
import { VolumeSliderRoot } from '../volume-slider-root';
|
||||
|
||||
// --- Hoisted mock data (available inside vi.mock factories) ---
|
||||
|
||||
const { mockSliderHandle, mockVolumeState } = vi.hoisted(() => ({
|
||||
mockSliderHandle: () => ({
|
||||
interaction: {
|
||||
current: {
|
||||
pointerPercent: 0,
|
||||
dragPercent: 0,
|
||||
dragging: false,
|
||||
pointing: false,
|
||||
focused: false,
|
||||
},
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
rootProps: {
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerLeave: vi.fn(),
|
||||
},
|
||||
thumbProps: {
|
||||
onKeyDown: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onBlur: vi.fn(),
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
mockVolumeState: {
|
||||
volume: 0.8,
|
||||
muted: false,
|
||||
volumeAvailability: 'available' as const,
|
||||
changeVolume: vi.fn(),
|
||||
toggleMute: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Module mocks ---
|
||||
|
||||
vi.mock('@videojs/core/dom', async (importOriginal) => {
|
||||
const orig: Record<string, unknown> = await importOriginal();
|
||||
return { ...orig, createSlider: vi.fn(mockSliderHandle) };
|
||||
});
|
||||
|
||||
vi.mock('@videojs/store/react', () => ({
|
||||
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
|
||||
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
|
||||
if (!selector) return _store;
|
||||
try {
|
||||
const result = selector({ volume: mockVolumeState });
|
||||
if (result !== undefined) return result;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
return selector(mockVolumeState);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('VolumeSliderRoot', () => {
|
||||
it('renders a div element', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-orientation]');
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('forwards ref', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot ref={ref} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('spreads additional props', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot data-testid="vol-slider" />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="vol-slider"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults orientation to horizontal', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-orientation]');
|
||||
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('sets slider CSS custom properties', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-orientation]') as HTMLElement;
|
||||
expect(el?.style.getPropertyValue('--media-slider-fill')).toBeTruthy();
|
||||
expect(el?.style.getPropertyValue('--media-slider-pointer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VolumeSlider compound', () => {
|
||||
it('renders all parts together', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot data-testid="root">
|
||||
<SliderTrack data-testid="track">
|
||||
<SliderFill data-testid="fill" />
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</SliderTrack>
|
||||
<SliderValue data-testid="value" />
|
||||
</VolumeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="value"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('thumb receives ARIA attributes from VolumeSliderCore', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot>
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</VolumeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const thumb = container.querySelector('[data-testid="thumb"]');
|
||||
expect(thumb?.getAttribute('role')).toBe('slider');
|
||||
expect(thumb?.getAttribute('aria-label')).toBe('Volume');
|
||||
});
|
||||
|
||||
it('SliderValue formats as percentage', () => {
|
||||
const { Wrapper } = createPlayerWrapper();
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<VolumeSliderRoot>
|
||||
<SliderValue data-testid="value" />
|
||||
</VolumeSliderRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const output = container.querySelector('[data-testid="value"]');
|
||||
expect(output?.textContent).toContain('%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { SliderDataAttrs, VolumeSliderCore } from '@videojs/core';
|
||||
import { getSliderCSSVars, logMissingFeature, selectVolume } from '@videojs/core/dom';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import { usePlayer } from '../../player/context';
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { useLatestRef } from '../../utils/use-latest-ref';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSlider } from '../hooks/use-slider';
|
||||
import { SliderProvider } from '../slider/slider-context';
|
||||
|
||||
const noopVolume = {
|
||||
volume: 0,
|
||||
muted: false,
|
||||
volumeAvailability: 'unsupported' as const,
|
||||
changeVolume: () => 0,
|
||||
toggleMute: () => false,
|
||||
};
|
||||
|
||||
export interface VolumeSliderRootProps
|
||||
extends UIComponentProps<'div', VolumeSliderCore.State>,
|
||||
Pick<VolumeSliderCore.Props, 'label' | 'orientation' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
|
||||
onDragStart?: (() => void) | undefined;
|
||||
onDragEnd?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export const VolumeSliderRoot = forwardRef<HTMLDivElement, VolumeSliderRootProps>(
|
||||
function VolumeSliderRoot(componentProps, forwardedRef) {
|
||||
const {
|
||||
render,
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
orientation,
|
||||
step = 1,
|
||||
largeStep = 10,
|
||||
disabled,
|
||||
thumbAlignment,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
...elementProps
|
||||
} = componentProps;
|
||||
|
||||
const volume = usePlayer(selectVolume);
|
||||
|
||||
const [core] = useState(() => new VolumeSliderCore());
|
||||
core.setProps({ label, orientation, step, largeStep, disabled, thumbAlignment });
|
||||
|
||||
// Keep a ref to the latest volume state for callbacks.
|
||||
const volumeRef = useLatestRef(volume);
|
||||
|
||||
const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider<VolumeSliderCore.State>({
|
||||
computeState: (interaction) => {
|
||||
if (!volume) {
|
||||
return core.getVolumeState(noopVolume, interaction);
|
||||
}
|
||||
return core.getVolumeState(volume, interaction);
|
||||
},
|
||||
getPercent: () => (volume ? volume.volume * 100 : 0),
|
||||
getStepPercent: () => step,
|
||||
getLargeStepPercent: () => largeStep,
|
||||
orientation,
|
||||
disabled,
|
||||
adjustPercent: (rawPercent, thumbSize, trackSize) =>
|
||||
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
|
||||
getCSSVars: getSliderCSSVars,
|
||||
onValueChange: (percent) => {
|
||||
volumeRef.current?.changeVolume(percent / 100);
|
||||
},
|
||||
onValueCommit: (percent) => {
|
||||
volumeRef.current?.changeVolume(percent / 100);
|
||||
},
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
if (__DEV__) logMissingFeature('VolumeSlider', 'volume');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SliderProvider
|
||||
value={{
|
||||
state,
|
||||
pointerValue: core.valueFromPercent(state.pointerPercent),
|
||||
thumbRef,
|
||||
thumbProps,
|
||||
stateAttrMap: SliderDataAttrs,
|
||||
getAttrs: (sliderState) => core.getAttrs(sliderState as VolumeSliderCore.State),
|
||||
formatValue: (value) => `${Math.round(value)}%`,
|
||||
}}
|
||||
>
|
||||
{renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: SliderDataAttrs,
|
||||
ref: [forwardedRef, rootRef],
|
||||
props: [{ style: cssVars }, rootProps, elementProps],
|
||||
}
|
||||
)}
|
||||
</SliderProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export namespace VolumeSliderRoot {
|
||||
export type Props = VolumeSliderRootProps;
|
||||
export type State = VolumeSliderCore.State;
|
||||
}
|
||||
Reference in New Issue
Block a user