feat: add volume range compound component (#19)

This commit is contained in:
Wesley Luyten
2025-09-23 11:07:47 -05:00
committed by GitHub
parent e3dcb6aba7
commit aefe890fee
8 changed files with 598 additions and 114 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import './globals.css';
function DemoPlayer() {
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-screen grid place-items-center gap-8 p-8">
<MediaProvider>
<div className="w-full max-w-4xl mx-auto">
<MediaSkinDefault>
@@ -5,7 +5,26 @@ import { volumeRangeStateDefinition } from '@vjs-10/media-store';
import { toConnectedHTMLComponent } from '../utils/component-factory';
export class VolumeRangeBase extends HTMLElement {
// Utility functions for pointer position and volume calculations
const calculatePointerRatio = (clientX: number, rect: DOMRect): number => {
const x = clientX - rect.left;
return Math.max(0, Math.min(100, (x / rect.width) * 100));
};
const calculateVolumeFromRatio = (ratio: number): number => {
return ratio / 100;
};
const calculateVolumeFromPointerEvent = (event: PointerEvent): number => {
const rect = (event.target as HTMLElement).getBoundingClientRect();
const ratio = calculatePointerRatio(event.clientX, rect);
return calculateVolumeFromRatio(ratio);
};
/**
* VolumeRange Root component - Main container with pointer event handling
*/
export class VolumeRangeRootBase extends HTMLElement {
_state:
| {
volume: number;
@@ -14,36 +33,89 @@ export class VolumeRangeBase extends HTMLElement {
requestVolumeChange: (volume: number) => void;
}
| undefined;
_input: HTMLInputElement;
_trackElement: HTMLElement | null = null;
_pointerPosition: number | null = null;
_hovering: boolean = false;
_dragging: boolean = false;
constructor() {
super();
/**
* @TODO This is just a simple placeholder input to demonstrate functionality.
* A full implementation will need to implement a "compound component" architecture and likely should use templates. (CJP)
**/
this._input = document.createElement('input');
this._input.type = 'range';
this._input.min = '0';
this._input.max = '1';
this._input.step = '0.01';
this._input.addEventListener('input', this);
this.appendChild(this._input);
// Add pointer event listeners
this.addEventListener('pointerdown', this);
this.addEventListener('pointermove', this);
this.addEventListener('pointerup', this);
this.addEventListener('pointerenter', this);
this.addEventListener('pointerleave', this);
}
handleEvent(event: Event): void {
const { type } = event;
const state = this._state;
if (state) {
if (type === 'input') {
state.requestVolumeChange(parseFloat(this._input.value));
}
if (!state) return;
switch (type) {
case 'pointerdown':
this._handlePointerDown(event as PointerEvent);
break;
case 'pointermove':
this._handlePointerMove(event as PointerEvent);
break;
case 'pointerup':
this._handlePointerUp(event as PointerEvent);
break;
case 'pointerenter':
this._handlePointerEnter();
break;
case 'pointerleave':
this._handlePointerLeave();
break;
}
}
get volume(): number {
return this._state?.volume ?? 0;
private _handlePointerDown(event: PointerEvent) {
event.preventDefault();
this._dragging = true;
const volume = calculateVolumeFromPointerEvent(event);
this._state!.requestVolumeChange(volume);
// Capture pointer events
this.setPointerCapture(event.pointerId);
}
private _handlePointerMove(event: PointerEvent) {
if (!this._trackElement) return;
const rect = this._trackElement.getBoundingClientRect();
const ratio = calculatePointerRatio(event.clientX, rect);
this._pointerPosition = ratio;
if (this._dragging) {
const volume = calculateVolumeFromRatio(ratio);
this._state!.requestVolumeChange(volume);
}
}
private _handlePointerUp(event: PointerEvent) {
this.releasePointerCapture(event.pointerId);
if (this._dragging && this._trackElement && this._pointerPosition !== null) {
const volume = calculateVolumeFromRatio(this._pointerPosition);
this._state!.requestVolumeChange(volume);
}
this._dragging = false;
}
private _handlePointerEnter() {
this._hovering = true;
}
private _handlePointerLeave() {
this._hovering = false;
}
get volume(): number | undefined {
return this._state?.volume;
}
get muted(): boolean {
@@ -56,19 +128,90 @@ export class VolumeRangeBase extends HTMLElement {
_update(props: any, state: any): void {
this._state = state;
const displayValue = state.muted ? 0 : state.volume;
this._input.value = displayValue.toString();
this._input.setAttribute('aria-label', props['aria-label']);
this._input.setAttribute('aria-valuetext', props['aria-valuetext']);
this._input.disabled = props.disabled ?? false;
// Update data attributes for styling
this.setAttribute('data-volume-level', props['data-volume-level']);
this.toggleAttribute('data-muted', props['data-muted']);
// Find track element
this._trackElement = this.querySelector('media-volume-range-track') as HTMLElement;
// Calculate slider fill percentage
const sliderFill =
this._dragging && this._pointerPosition !== null
? this._pointerPosition
: state.muted
? 0
: state.volume * 100;
// Update CSS custom properties
this.style.setProperty('--slider-fill', `${Math.round(sliderFill)}%`);
this.style.setProperty(
'--slider-pointer',
this._hovering && this._pointerPosition !== null ? `${Math.round(this._pointerPosition)}%` : '0%'
);
// Update ARIA attributes
this.setAttribute('role', 'slider');
this.setAttribute('aria-label', props['aria-label'] || 'Volume');
this.setAttribute('aria-valuemin', '0');
this.setAttribute('aria-valuemax', '100');
this.setAttribute('aria-valuenow', sliderFill.toString());
this.setAttribute('aria-valuetext', props['aria-valuetext'] || '');
// Update data attributes
this.setAttribute('data-muted', state.muted.toString());
this.setAttribute('data-volume-level', state.volumeLevel);
}
}
export const useVolumeRangeState: StateHook<{
/**
* VolumeRange Track component - Track element that captures pointer events
*/
export class VolumeRangeTrackBase extends HTMLElement {
constructor() {
super();
}
_update(_props: any, _state: any): void {
// Track doesn't need much state management
}
}
/**
* VolumeRange Progress component - Shows current progress
*/
export class VolumeRangeProgressBase extends HTMLElement {
constructor() {
super();
this.style.position = 'absolute';
this.style.width = 'var(--slider-fill, 0%)';
this.style.height = '100%';
}
_update(_props: any, _state: any): void {
// Progress updates are handled by CSS custom properties
}
}
/**
* VolumeRange Thumb component - Draggable thumb element
*/
export class VolumeRangeThumbBase extends HTMLElement {
constructor() {
super();
this.style.position = 'absolute';
this.style.top = '50%';
this.style.left = 'var(--slider-fill, 0%)';
this.style.transform = 'translate(-50%, -50%)';
}
_update(_props: any, _state: any): void {
// Thumb updates are handled by CSS custom properties
}
}
/**
* VolumeRange Root state hook - equivalent to React's useVolumeRangeRootState
* Handles media store state subscription and transformation
*/
export const useVolumeRangeRootState: StateHook<{
volume: number;
muted: boolean;
volumeLevel: string;
@@ -80,40 +223,127 @@ export const useVolumeRangeState: StateHook<{
}),
};
export const useVolumeRangeProps: PropsHook<{
/**
* VolumeRange Root props hook - equivalent to React's useVolumeRangeRootProps
* Handles element attributes and properties based on state
*/
export const useVolumeRangeRootProps: PropsHook<{
volume: number;
muted: boolean;
volumeLevel: string;
}> = (state, _element) => {
const displayValue = state.muted ? 0 : state.volume;
const volumeText = `${Math.round(state.muted ? 0 : state.volume * 100)}%`;
const baseProps: Record<string, any> = {
/** data attributes/props */
['data-muted']: state.muted,
['data-muted']: state.muted.toString(),
['data-volume-level']: state.volumeLevel,
/** aria attributes/props */
['aria-label']: 'Volume',
['aria-valuetext']: `${Math.round(displayValue * 100)}%`,
/** input props */
disabled: false,
['aria-valuetext']: volumeText,
};
return baseProps;
};
// @TODO When implementing compound components, this function may need to be swapped out, modified, or augmented in some way or another. (CJP)
/**
* VolumeRange Track props hook
*/
export const useVolumeRangeTrackProps: PropsHook<{}> = (_state, _element) => {
return {};
};
export const VolumeRange: ConnectedComponentConstructor<VolumeRangeState> = toConnectedHTMLComponent(
VolumeRangeBase,
useVolumeRangeState,
useVolumeRangeProps,
'VolumeRange'
/**
* VolumeRange Progress props hook
*/
export const useVolumeRangeProgressProps: PropsHook<{}> = (_state, _element) => {
return {};
};
/**
* VolumeRange Thumb props hook
*/
export const useVolumeRangeThumbProps: PropsHook<{}> = (_state, _element) => {
return {};
};
/**
* Connected VolumeRange Root component using hook-style architecture
*/
export const VolumeRangeRoot: ConnectedComponentConstructor<VolumeRangeState> = toConnectedHTMLComponent(
VolumeRangeRootBase,
useVolumeRangeRootState,
useVolumeRangeRootProps,
'VolumeRangeRoot'
);
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-volume-range')) {
/**
* Connected VolumeRange Track component
*/
export const VolumeRangeTrack: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
VolumeRangeTrackBase,
{ keys: [], transform: () => ({}) },
useVolumeRangeTrackProps,
'VolumeRangeTrack'
);
/**
* Connected VolumeRange Progress component
*/
export const VolumeRangeProgress: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
VolumeRangeProgressBase,
{ keys: [], transform: () => ({}) },
useVolumeRangeProgressProps,
'VolumeRangeProgress'
);
/**
* Connected VolumeRange Thumb component
*/
export const VolumeRangeThumb: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
VolumeRangeThumbBase,
{ keys: [], transform: () => ({}) },
useVolumeRangeThumbProps,
'VolumeRangeThumb'
);
/**
* Compound VolumeRange component object
*/
export const VolumeRange = Object.assign(
{},
{
Root: VolumeRangeRoot,
Track: VolumeRangeTrack,
Progress: VolumeRangeProgress,
Thumb: VolumeRangeThumb,
}
) as {
Root: typeof VolumeRangeRoot;
Track: typeof VolumeRangeTrack;
Progress: typeof VolumeRangeProgress;
Thumb: typeof VolumeRangeThumb;
};
// Register custom elements
if (!globalThis.customElements.get('media-volume-range-root')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-volume-range', VolumeRange);
globalThis.customElements.define('media-volume-range-root', VolumeRangeRoot);
}
if (!globalThis.customElements.get('media-volume-range-track')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-volume-range-track', VolumeRangeTrack);
}
if (!globalThis.customElements.get('media-volume-range-progress')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-volume-range-progress', VolumeRangeProgress);
}
if (!globalThis.customElements.get('media-volume-range-thumb')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-volume-range-thumb', VolumeRangeThumb);
}
export default VolumeRange;
@@ -142,6 +142,40 @@ export function getTemplateHTML() {
background-color: #007bff;
border-radius: inherit;
}
/* VolumeRange Component Styles */
media-volume-range-root {
display: flex;
align-items: center;
position: relative;
min-width: 80px;
width: 80px;
padding-block: .75rem;
margin: 0 .5rem;
}
media-volume-range-track {
position: relative;
width: 100%;
height: .375rem;
background-color: #e0e0e0;
border-radius: .25rem;
overflow: hidden;
pointer-events: none;
}
media-volume-range-thumb {
width: .75rem;
height: .75rem;
background-color: #fff;
border-radius: 50%;
pointer-events: none;
}
media-volume-range-progress {
background-color: #007bff;
border-radius: inherit;
}
</style>
<media-container>
<slot name="media" slot="media"></slot>
@@ -168,7 +202,12 @@ export function getTemplateHTML() {
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
</media-mute-button>
<media-volume-range></media-volume-range>
<media-volume-range-root>
<media-volume-range-track>
<media-volume-range-progress></media-volume-range-progress>
</media-volume-range-track>
<media-volume-range-thumb></media-volume-range-thumb>
</media-volume-range-root>
<media-fullscreen-button class="button">
<media-fullscreen-enter-icon class="icon fullscreen-enter-icon"></media-fullscreen-enter-icon>
<media-fullscreen-exit-icon class="icon fullscreen-exit-icon"></media-fullscreen-exit-icon>
@@ -1,5 +1,5 @@
import type { ConnectedComponent } from '../utils/component-factory';
import type { HTMLAttributes, PointerEvent, PropsWithChildren } from 'react';
import type { PointerEvent, PropsWithChildren } from 'react';
import { useCallback, useMemo, useState } from 'react';
@@ -212,7 +212,7 @@ const TimeRangeTrack: ConnectedComponent<TimeRangeTrackProps, typeof renderTimeR
// THUMB COMPONENT
// ============================================================================
export const useTimeRangeThumbProps = (props: HTMLAttributes<HTMLDivElement>): Record<string, unknown> => {
export const useTimeRangeThumbProps = (props: React.HTMLAttributes<HTMLDivElement>): React.HTMLAttributes<HTMLDivElement> => {
return {
...props,
style: {
@@ -242,7 +242,7 @@ const TimeRangeThumb: ConnectedComponent<TimeRangeThumbProps, typeof renderTimeR
// POINTER COMPONENT
// ============================================================================
export const useTimeRangePointerProps = (props: HTMLAttributes<HTMLDivElement>): Record<string, unknown> => {
export const useTimeRangePointerProps = (props: React.HTMLAttributes<HTMLDivElement>): React.HTMLAttributes<HTMLDivElement> => {
return {
...props,
style: {
@@ -271,7 +271,7 @@ const TimeRangePointer: ConnectedComponent<TimeRangePointerProps, typeof renderT
// PROGRESS COMPONENT
// ============================================================================
export const useTimeRangeProgressProps = (props: HTMLAttributes<HTMLDivElement>): Record<string, unknown> => {
export const useTimeRangeProgressProps = (props: React.HTMLAttributes<HTMLDivElement>): React.HTMLAttributes<HTMLDivElement> => {
return {
...props,
style: {
@@ -1,97 +1,265 @@
import type { ConnectedComponent } from '../utils/component-factory';
import type { ChangeEvent, PropsWithChildren } from 'react';
import type { ConnectedComponent, ContextComponent } from '../utils/component-factory';
import { useMemo } from 'react';
import React from 'react';
import { volumeRangeStateDefinition } from '@vjs-10/media-store';
import { shallowEqual, useMediaSelector, useMediaStore } from '@vjs-10/react-media-store';
import { toConnectedComponent } from '../utils/component-factory';
import { toConnectedComponent, toContextComponent } from '../utils/component-factory';
// Utility functions for pointer position and volume calculations
const calculatePointerRatio = (clientX: number, rect: DOMRect): number => {
const x = clientX - rect.left;
return Math.max(0, Math.min(100, (x / rect.width) * 100));
};
export const useVolumeRangeState = (
_props: any
): {
const calculateVolumeFromRatio = (ratio: number): number => {
return ratio / 100;
};
const calculateVolumeFromPointerEvent = (e: React.PointerEvent<HTMLDivElement>): number => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = calculatePointerRatio(e.clientX, rect);
return calculateVolumeFromRatio(ratio);
};
// ============================================================================
// ROOT COMPONENT
// ============================================================================
export const useVolumeRangeRootState = (_props: any): {
volume: number;
muted: boolean;
volumeLevel: 'off' | 'low' | 'medium' | 'high';
volumeLevel: string;
requestVolumeChange: (volume: number) => void;
pointerPosition: number | null;
setPointerPosition: (position: number | null) => void;
hovering: boolean;
setHovering: (hovering: boolean) => void;
dragging: boolean;
setDragging: (dragging: boolean) => void;
trackRef: HTMLDivElement | null;
setTrackRef: (ref: HTMLDivElement | null) => void;
} => {
const mediaStore = useMediaStore();
/** @TODO Fix type issues with hooks (CJP) */
const mediaState = useMediaSelector(volumeRangeStateDefinition.stateTransform, shallowEqual);
const methods = useMemo(() => volumeRangeStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
const methods = React.useMemo(() => volumeRangeStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]);
const { requestVolumeChange } = methods;
const [pointerPosition, setPointerPosition] = React.useState<number | null>(null);
const [hovering, setHovering] = React.useState(false);
const [dragging, setDragging] = React.useState(false);
const [trackRef, setTrackRef] = React.useState<HTMLDivElement | null>(null);
return {
volume: mediaState.volume,
muted: mediaState.muted,
volumeLevel: mediaState.volumeLevel,
requestVolumeChange: methods.requestVolumeChange,
requestVolumeChange: requestVolumeChange,
pointerPosition: pointerPosition,
setPointerPosition: setPointerPosition,
hovering: hovering,
setHovering: setHovering,
dragging: dragging,
setDragging: setDragging,
trackRef: trackRef,
setTrackRef: setTrackRef,
};
};
export type useVolumeRangeState = typeof useVolumeRangeState;
export const useVolumeRangeRootProps = (
props: React.PropsWithChildren<{ [k: string]: any }>,
state: ReturnType<typeof useVolumeRangeRootState>
) => {
// When dragging, use pointer position for immediate feedback; otherwise use current volume
const sliderFill =
state.dragging && state.pointerPosition !== null
? state.pointerPosition
: state.muted
? 0
: state.volume * 100;
export type VolumeRangeState = ReturnType<useVolumeRangeState>;
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
state.setDragging(true);
const volume = calculateVolumeFromPointerEvent(e);
state.requestVolumeChange(volume);
export const useVolumeRangeProps = (
props: PropsWithChildren<{ [k: string]: any }>,
state: ReturnType<typeof useVolumeRangeState>
): Record<string, any> => {
const displayValue = state.muted ? 0 : state.volume;
// Capture pointer events to ensure we receive move and up events even if pointer leaves element
e.currentTarget.setPointerCapture(e.pointerId);
},
[state.setDragging, state.requestVolumeChange]
);
const baseProps: Record<string, any> = {
/** @TODO These should probably be defined in the render function (CJP) */
/** input properties */
type: 'range',
min: '0',
max: '1',
step: '0.01',
value: displayValue,
/** aria attributes/props */
const handlePointerMove = React.useCallback(
(e: PointerEvent) => {
if (!state.trackRef) return;
const rect = state.trackRef.getBoundingClientRect();
const ratio = calculatePointerRatio(e.clientX, rect);
state.setPointerPosition(ratio);
if (state.dragging) {
const volume = calculateVolumeFromRatio(ratio);
state.requestVolumeChange(volume);
}
},
[state.trackRef, state.setPointerPosition, state.dragging, state.requestVolumeChange]
);
const handlePointerUp = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId);
if (state.dragging && state.trackRef && state.pointerPosition !== null) {
const volume = calculateVolumeFromRatio(state.pointerPosition);
state.requestVolumeChange(volume);
}
state.setDragging(false);
},
[state.dragging, state.trackRef, state.pointerPosition, state.requestVolumeChange, state.setDragging]
);
const handlePointerEnter = React.useCallback(() => {
state.setHovering(true);
}, [state.setHovering]);
const handlePointerLeave = React.useCallback(() => {
state.setHovering(false);
}, [state.setHovering]);
const volumeText = `${Math.round(state.muted ? 0 : state.volume * 100)}%`;
return {
role: 'slider',
'aria-label': 'Volume',
'aria-valuetext': `${Math.round(displayValue * 100)}%`,
/** data attributes */
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': sliderFill,
'aria-valuetext': volumeText,
'data-muted': state.muted,
'data-volume-level': state.volumeLevel,
/** external props spread last to allow for overriding */
style: {
...props.style,
'--slider-fill': `${sliderFill.toFixed(3)}%`,
'--slider-pointer':
state.hovering && state.pointerPosition !== null ? `${state.pointerPosition.toFixed(3)}%` : '0%',
},
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
...props,
};
return baseProps;
} as React.PropsWithChildren<{ [k: string]: any }>;
};
export type useVolumeRangeProps = typeof useVolumeRangeProps;
type VolumeRangeProps = ReturnType<useVolumeRangeProps>;
type useVolumeRangeRootState = typeof useVolumeRangeRootState;
type useVolumeRangeRootProps = typeof useVolumeRangeRootProps;
type VolumeRangeRootProps = ReturnType<useVolumeRangeRootProps>;
/**
* @TODO This is just a simple render function to demonstrate functionality.
* A full implementation will need to implement a "compound component" architecture. (CJP)
**/
export const renderVolumeRange = (props: VolumeRangeProps, state: VolumeRangeState): JSX.Element => {
return (
<input
{...props}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
/** @ts-ignore */
if (props.disabled) return;
state.requestVolumeChange(parseFloat(e.target.value));
}}
/>
);
export const renderVolumeRangeRoot = (props: VolumeRangeRootProps): JSX.Element => {
return <div {...props} />;
};
export type renderVolumeRange = typeof renderVolumeRange;
/**
* @TODO When implementing compound components, this function may need to be swapped out, modified, or augmented in some way or another. (CJP)
*/
export const VolumeRange: ConnectedComponent<VolumeRangeProps, typeof renderVolumeRange> = toConnectedComponent(
useVolumeRangeState,
useVolumeRangeProps,
renderVolumeRange,
'VolumeRange'
const VolumeRangeRoot: ConnectedComponent<VolumeRangeRootProps, typeof renderVolumeRangeRoot> = toConnectedComponent(
useVolumeRangeRootState,
useVolumeRangeRootProps,
renderVolumeRangeRoot,
'VolumeRange.Root'
);
// ============================================================================
// TRACK COMPONENT
// ============================================================================
export const useVolumeRangeTrackProps = (props: React.PropsWithChildren<{ [k: string]: any }>, context: any): React.PropsWithChildren<{ [k: string]: any }> & { ref: (ref: HTMLDivElement | null) => void } => {
const { setTrackRef } = context;
return {
ref: setTrackRef,
...props,
} as React.PropsWithChildren<{ [k: string]: any }> & { ref: (ref: HTMLDivElement | null) => void };
};
type useVolumeRangeTrackProps = typeof useVolumeRangeTrackProps;
type VolumeRangeTrackProps = ReturnType<useVolumeRangeTrackProps>;
export const renderVolumeRangeTrack = (props: VolumeRangeTrackProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeTrack: ContextComponent<any, any> = toContextComponent(useVolumeRangeTrackProps, renderVolumeRangeTrack, 'VolumeRange.Track');
// ============================================================================
// THUMB COMPONENT
// ============================================================================
export const useVolumeRangeThumbProps = (props: React.HTMLAttributes<HTMLDivElement>): React.HTMLAttributes<HTMLDivElement> => {
return {
...props,
style: {
...props.style,
insetInlineStart: 'var(--slider-fill)',
position: 'absolute' as const,
top: '50%',
transform: 'translate(-50%, -50%)',
},
};
};
type useVolumeRangeThumbProps = typeof useVolumeRangeThumbProps;
type VolumeRangeThumbProps = ReturnType<useVolumeRangeThumbProps>;
export const renderVolumeRangeThumb = (props: VolumeRangeThumbProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeThumb: ContextComponent<any, any> = toContextComponent(useVolumeRangeThumbProps, renderVolumeRangeThumb, 'VolumeRange.Thumb');
// ============================================================================
// PROGRESS COMPONENT
// ============================================================================
export const useVolumeRangeProgressProps = (props: React.HTMLAttributes<HTMLDivElement>): React.HTMLAttributes<HTMLDivElement> => {
return {
...props,
style: {
...props.style,
width: 'var(--slider-fill, 0%)',
position: 'absolute' as const,
height: '100%',
},
};
};
type useVolumeRangeProgressProps = typeof useVolumeRangeProgressProps;
type VolumeRangeProgressProps = ReturnType<useVolumeRangeProgressProps>;
export const renderVolumeRangeProgress = (props: VolumeRangeProgressProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeProgress: ContextComponent<any, any> = toContextComponent(useVolumeRangeProgressProps, renderVolumeRangeProgress, 'VolumeRange.Progress');
// ============================================================================
// EXPORTS
// ============================================================================
export const VolumeRange = Object.assign(
{},
{
Root: VolumeRangeRoot,
Track: VolumeRangeTrack,
Thumb: VolumeRangeThumb,
Progress: VolumeRangeProgress,
}
) as {
Root: typeof VolumeRangeRoot;
Track: typeof VolumeRangeTrack;
Thumb: typeof VolumeRangeThumb;
Progress: typeof VolumeRangeProgress;
};
export default VolumeRange;
@@ -16,7 +16,7 @@ import { FullscreenButton } from '../components/FullscreenButton';
import { MediaContainer } from '../components/MediaContainer';
import MuteButton from '../components/MuteButton';
import PlayButton from '../components/PlayButton';
// import { VolumeRange } from '../components/VolumeRange';
import { VolumeRange } from '../components/VolumeRange';
import { TimeRange } from '../components/TimeRange';
export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -57,8 +57,12 @@ export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({ chil
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
{/* TODO: Volume slider in a popover (requires building a popover and vertical orientation slider) or we just inline it on larger displays? */}
{/* <VolumeRange className={legacyStyles.VolumeRange} /> */}
<VolumeRange.Root className={styles.VolumeRangeRoot}>
<VolumeRange.Track className={styles.VolumeRangeTrack}>
<VolumeRange.Progress className={styles.VolumeRangeProgress} />
</VolumeRange.Track>
<VolumeRange.Thumb className={styles.VolumeRangeThumb} />
</VolumeRange.Root>
<FullscreenButton className={`${styles.Button} ${styles.IconButton} ${styles.FullScreenButton}`}>
<FullscreenEnterIcon className={styles.FullScreenEnterIcon} />
@@ -145,3 +145,36 @@
background-color: #007bff;
border-radius: inherit;
}
/* VolumeRange Component Styles */
.VolumeRangeRoot {
display: flex;
align-items: center;
position: relative;
min-width: 80px;
width: 80px;
padding-block: 0.75rem;
margin: 0 0.5rem;
}
.VolumeRangeTrack {
position: relative;
width: 100%;
height: 0.375rem;
background-color: #e0e0e0;
border-radius: 0.25rem;
overflow: hidden;
}
.VolumeRangeThumb {
width: 0.75rem;
height: 0.75rem;
background-color: #fff;
border-radius: 50%;
pointer-events: none;
}
.VolumeRangeProgress {
background-color: #007bff;
border-radius: inherit;
}
+10
View File
@@ -112,6 +112,16 @@ const styles: Record<string, string> = {
'group-hover/slider:opacity-100 group-focus-within/slider:opacity-100',
'size-2.5 active:size-3 group-active/slider:size-3'
),
VolumeRangeRoot: cn('flex h-5 items-center w-20 group/slider relative'),
VolumeRangeTrack: cn('h-1 w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5'),
VolumeRangeProgress: cn('bg-white rounded-[inherit]'),
VolumeRangeThumb: 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'
),
};
export default styles;