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;