diff --git a/examples/react-demo/src/main.tsx b/examples/react-demo/src/main.tsx
index 250236dd..45b7826e 100644
--- a/examples/react-demo/src/main.tsx
+++ b/examples/react-demo/src/main.tsx
@@ -6,7 +6,7 @@ import './globals.css';
function DemoPlayer() {
return (
-
+
diff --git a/packages/html/html/src/components/media-volume-range.ts b/packages/html/html/src/components/media-volume-range.ts
index c4354466..28053211 100644
--- a/packages/html/html/src/components/media-volume-range.ts
+++ b/packages/html/html/src/components/media-volume-range.ts
@@ -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 = {
/** 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 = 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 = 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 = toConnectedHTMLComponent(
+ VolumeRangeTrackBase,
+ { keys: [], transform: () => ({}) },
+ useVolumeRangeTrackProps,
+ 'VolumeRangeTrack'
+);
+
+/**
+ * Connected VolumeRange Progress component
+ */
+export const VolumeRangeProgress: ConnectedComponentConstructor = toConnectedHTMLComponent(
+ VolumeRangeProgressBase,
+ { keys: [], transform: () => ({}) },
+ useVolumeRangeProgressProps,
+ 'VolumeRangeProgress'
+);
+
+/**
+ * Connected VolumeRange Thumb component
+ */
+export const VolumeRangeThumb: ConnectedComponentConstructor = 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;
diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts
index 8c557005..2711d70b 100644
--- a/packages/html/html/src/skins/media-skin-default.ts
+++ b/packages/html/html/src/skins/media-skin-default.ts
@@ -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;
+ }
@@ -168,7 +202,12 @@ export function getTemplateHTML() {
-
+
+
+
+
+
+
diff --git a/packages/react/react/src/components/TimeRange.tsx b/packages/react/react/src/components/TimeRange.tsx
index 72a70df0..289107d0 100644
--- a/packages/react/react/src/components/TimeRange.tsx
+++ b/packages/react/react/src/components/TimeRange.tsx
@@ -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): Record => {
+export const useTimeRangeThumbProps = (props: React.HTMLAttributes): React.HTMLAttributes => {
return {
...props,
style: {
@@ -242,7 +242,7 @@ const TimeRangeThumb: ConnectedComponent): Record => {
+export const useTimeRangePointerProps = (props: React.HTMLAttributes): React.HTMLAttributes => {
return {
...props,
style: {
@@ -271,7 +271,7 @@ const TimeRangePointer: ConnectedComponent): Record => {
+export const useTimeRangeProgressProps = (props: React.HTMLAttributes): React.HTMLAttributes => {
return {
...props,
style: {
diff --git a/packages/react/react/src/components/VolumeRange.tsx b/packages/react/react/src/components/VolumeRange.tsx
index 1554c36d..3b6d5db9 100644
--- a/packages/react/react/src/components/VolumeRange.tsx
+++ b/packages/react/react/src/components/VolumeRange.tsx
@@ -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): 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(null);
+ const [hovering, setHovering] = React.useState(false);
+ const [dragging, setDragging] = React.useState(false);
+ const [trackRef, setTrackRef] = React.useState(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
+) => {
+ // 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;
+ const handlePointerDown = React.useCallback(
+ (e: React.PointerEvent) => {
+ e.preventDefault();
+ state.setDragging(true);
+ const volume = calculateVolumeFromPointerEvent(e);
+ state.requestVolumeChange(volume);
-export const useVolumeRangeProps = (
- props: PropsWithChildren<{ [k: string]: any }>,
- state: ReturnType
-): Record => {
- 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 = {
- /** @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) => {
+ 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;
+type useVolumeRangeRootState = typeof useVolumeRangeRootState;
+type useVolumeRangeRootProps = typeof useVolumeRangeRootProps;
+type VolumeRangeRootProps = ReturnType;
-/**
- * @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 (
- ) => {
- /** @ts-ignore */
- if (props.disabled) return;
- state.requestVolumeChange(parseFloat(e.target.value));
- }}
- />
- );
+export const renderVolumeRangeRoot = (props: VolumeRangeRootProps): JSX.Element => {
+ return ;
};
-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 = toConnectedComponent(
- useVolumeRangeState,
- useVolumeRangeProps,
- renderVolumeRange,
- 'VolumeRange'
+const VolumeRangeRoot: ConnectedComponent = 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;
+
+export const renderVolumeRangeTrack = (props: VolumeRangeTrackProps): JSX.Element => {
+ return ;
+};
+
+const VolumeRangeTrack: ContextComponent = toContextComponent(useVolumeRangeTrackProps, renderVolumeRangeTrack, 'VolumeRange.Track');
+
+// ============================================================================
+// THUMB COMPONENT
+// ============================================================================
+
+export const useVolumeRangeThumbProps = (props: React.HTMLAttributes): React.HTMLAttributes => {
+ 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;
+
+export const renderVolumeRangeThumb = (props: VolumeRangeThumbProps): JSX.Element => {
+ return ;
+};
+
+const VolumeRangeThumb: ContextComponent = toContextComponent(useVolumeRangeThumbProps, renderVolumeRangeThumb, 'VolumeRange.Thumb');
+
+// ============================================================================
+// PROGRESS COMPONENT
+// ============================================================================
+
+export const useVolumeRangeProgressProps = (props: React.HTMLAttributes): React.HTMLAttributes => {
+ return {
+ ...props,
+ style: {
+ ...props.style,
+ width: 'var(--slider-fill, 0%)',
+ position: 'absolute' as const,
+ height: '100%',
+ },
+ };
+};
+
+type useVolumeRangeProgressProps = typeof useVolumeRangeProgressProps;
+type VolumeRangeProgressProps = ReturnType;
+
+export const renderVolumeRangeProgress = (props: VolumeRangeProgressProps): JSX.Element => {
+ return ;
+};
+
+const VolumeRangeProgress: ContextComponent = 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;
diff --git a/packages/react/react/src/skins/MediaSkinDefault.tsx b/packages/react/react/src/skins/MediaSkinDefault.tsx
index c5281715..60947eb8 100644
--- a/packages/react/react/src/skins/MediaSkinDefault.tsx
+++ b/packages/react/react/src/skins/MediaSkinDefault.tsx
@@ -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
- {/* TODO: Volume slider in a popover (requires building a popover and vertical orientation slider) or we just inline it on larger displays? */}
- {/* */}
+
+
+
+
+
+
diff --git a/packages/react/react/src/skins/styles.module.css b/packages/react/react/src/skins/styles.module.css
index 4f0a0a6c..1d3255c0 100644
--- a/packages/react/react/src/skins/styles.module.css
+++ b/packages/react/react/src/skins/styles.module.css
@@ -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;
+}
diff --git a/packages/react/react/src/skins/styles.ts b/packages/react/react/src/skins/styles.ts
index 367cab63..d972fa4a 100644
--- a/packages/react/react/src/skins/styles.ts
+++ b/packages/react/react/src/skins/styles.ts
@@ -112,6 +112,16 @@ const styles: Record = {
'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;