mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add volume range compound component (#19)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user