From 5e3cb8ad8134ecaf20b6e021e301a38b8ed06de2 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 26 Sep 2025 17:57:21 -0500 Subject: [PATCH] feat: add range orientation to react components (#30) --- packages/core/core/src/components/range.ts | 4 +- .../react/react/src/components/TimeRange.tsx | 131 +++++++++++------- .../react/src/components/VolumeRange.tsx | 125 ++++++++++------- .../src/skins/default/MediaSkinDefault.tsx | 2 +- .../react/react/src/skins/default/styles.ts | 8 +- 5 files changed, 159 insertions(+), 111 deletions(-) diff --git a/packages/core/core/src/components/range.ts b/packages/core/core/src/components/range.ts index 77e1a672..0ef60eef 100644 --- a/packages/core/core/src/components/range.ts +++ b/packages/core/core/src/components/range.ts @@ -91,8 +91,8 @@ export class Range { return getPointProgressOnLine( evt.clientX, evt.clientY, - { x: rect.left, y: rect.top }, - { x: rect.right, y: rect.bottom } + { x: rect.left, y: rect.bottom }, + { x: rect.right, y: rect.top } ); } diff --git a/packages/react/react/src/components/TimeRange.tsx b/packages/react/react/src/components/TimeRange.tsx index 057c888e..ba499239 100644 --- a/packages/react/react/src/components/TimeRange.tsx +++ b/packages/react/react/src/components/TimeRange.tsx @@ -1,5 +1,4 @@ import type { ConnectedComponent } from '../utils/component-factory'; -import type { PropsWithChildren } from 'react'; import { useCallback, useMemo } from 'react'; @@ -9,18 +8,32 @@ import { shallowEqual, useMediaSelector, useMediaStore } from '@vjs-10/react-med import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory'; +export namespace TimeRange { + export interface State { + currentTime: number; + duration: number; + requestSeek: (time: number) => void; + core: CoreTimeRange; + orientation: 'horizontal' | 'vertical'; + } + + export interface Props extends React.ComponentProps<'div'> { + orientation?: 'horizontal' | 'vertical'; + } +} + +interface TimeRangeRenderProps extends React.ComponentProps<'div'> { + 'data-orientation'?: 'horizontal' | 'vertical'; + 'data-current-time'?: number; + 'data-duration'?: number; +} + // ============================================================================ // ROOT COMPONENT // ============================================================================ -export const useTimeRangeRootState = ( - _props: any -): { - currentTime: number; - duration: number; - requestSeek: (time: number) => void; - core: CoreTimeRange; -} => { +export const useTimeRangeRootState = (props: TimeRange.Props): TimeRange.State => { + const { orientation = 'horizontal' } = props; const mediaStore = useMediaStore(); const mediaState = useMediaSelector(timeRangeStateDefinition.stateTransform, shallowEqual); const mediaMethods = useMemo(() => timeRangeStateDefinition.createRequestMethods(mediaStore.dispatch), [mediaStore]); @@ -29,46 +42,49 @@ export const useTimeRangeRootState = ( return { ...mediaState, ...mediaMethods, + orientation, core, }; }; -export const useTimeRangeRootProps = ( - props: PropsWithChildren<{ [k: string]: any }>, - state: ReturnType -) => { +export const useTimeRangeRootProps = (props: TimeRange.Props, state: TimeRange.State): TimeRangeRenderProps => { const { _fillWidth, _pointerWidth, _currentTimeText, _durationText } = state.core.getState(); + const { children, className, id, style, orientation = 'horizontal' } = props; + return { ref: useCallback((el: HTMLDivElement) => { state.core?.attach(el); }, []), + id, role: 'slider', 'aria-label': 'Seek', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': _fillWidth, 'aria-valuetext': `${_currentTimeText} of ${_durationText}`, + 'aria-orientation': orientation, + 'data-orientation': orientation, 'data-current-time': state.currentTime, 'data-duration': state.duration, + className, style: { - ...props.style, + ...style, '--slider-fill': `${_fillWidth.toFixed(3)}%`, '--slider-pointer': `${_pointerWidth.toFixed(3)}%`, - }, - ...props, - } as PropsWithChildren<{ [k: string]: any }>; + } as React.CSSProperties, + children, + }; }; type useTimeRangeRootState = typeof useTimeRangeRootState; type useTimeRangeRootProps = typeof useTimeRangeRootProps; -type TimeRangeRootProps = ReturnType; -export const renderTimeRangeRoot = (props: TimeRangeRootProps): JSX.Element => { +export const renderTimeRangeRoot = (props: TimeRangeRenderProps): JSX.Element => { return
; }; -const TimeRangeRoot: ConnectedComponent = toConnectedComponent( +const TimeRangeRoot: ConnectedComponent = toConnectedComponent( useTimeRangeRootState, useTimeRangeRootProps, renderTimeRangeRoot, @@ -80,25 +96,29 @@ const TimeRangeRoot: ConnectedComponent>, - context: any -): PropsWithChildren> & { ref?: any } => { + props: React.ComponentProps<'div'>, + context: TimeRange.State +): TimeRangeRenderProps => { return { ref: useCallback((el: HTMLDivElement) => { context.core?.setState({ _trackElement: el }); }, []), + 'data-orientation': context.orientation, ...props, + style: { + ...props.style, + [context.orientation === 'horizontal' ? 'width' : 'height']: '100%', + }, }; }; type useTimeRangeTrackProps = typeof useTimeRangeTrackProps; -type TimeRangeTrackProps = ReturnType; -export const renderTimeRangeTrack = (props: TimeRangeTrackProps): JSX.Element => { +export const renderTimeRangeTrack = (props: TimeRangeRenderProps): JSX.Element => { return
; }; -const TimeRangeTrack: ConnectedComponent = toContextComponent( +const TimeRangeTrack: ConnectedComponent, typeof renderTimeRangeTrack> = toContextComponent( useTimeRangeTrackProps, renderTimeRangeTrack, 'TimeRange.Track' @@ -109,28 +129,29 @@ const TimeRangeTrack: ConnectedComponent -): React.HTMLAttributes => { + props: React.ComponentProps<'div'>, + context: TimeRange.State +): TimeRangeRenderProps => { return { + 'data-orientation': context.orientation, ...props, style: { ...props.style, - insetInlineStart: 'var(--slider-fill)', + [context.orientation === 'horizontal' ? 'insetInlineStart' : 'insetBlockEnd']: 'var(--slider-fill)', + [context.orientation === 'horizontal' ? 'top' : 'left']: '50%', + translate: context.orientation === 'horizontal' ? '-50% -50%' : '-50% 50%', position: 'absolute' as const, - top: '50%', - transform: 'translate(-50%, -50%)', }, }; }; type useTimeRangeThumbProps = typeof useTimeRangeThumbProps; -type TimeRangeThumbProps = ReturnType; -export const renderTimeRangeThumb = (props: TimeRangeThumbProps): JSX.Element => { +export const renderTimeRangeThumb = (props: TimeRangeRenderProps): JSX.Element => { return
; }; -const TimeRangeThumb: ConnectedComponent = toContextComponent( +const TimeRangeThumb: ConnectedComponent, typeof renderTimeRangeThumb> = toContextComponent( useTimeRangeThumbProps, renderTimeRangeThumb, 'TimeRange.Thumb' @@ -141,59 +162,63 @@ const TimeRangeThumb: ConnectedComponent -): React.HTMLAttributes => { + props: React.ComponentProps<'div'>, + context: TimeRange.State +): TimeRangeRenderProps => { return { + 'data-orientation': context.orientation, ...props, style: { ...props.style, - width: 'var(--slider-pointer, 0%)', + [context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-pointer, 0%)', + [context.orientation === 'horizontal' ? 'height' : 'width']: '100%', position: 'absolute' as const, - height: '100%', }, }; }; type useTimeRangePointerProps = typeof useTimeRangePointerProps; -type TimeRangePointerProps = ReturnType; -export const renderTimeRangePointer = (props: TimeRangePointerProps): JSX.Element => { +export const renderTimeRangePointer = (props: TimeRangeRenderProps): JSX.Element => { return
; }; -const TimeRangePointer: ConnectedComponent = toContextComponent( - useTimeRangePointerProps, - renderTimeRangePointer, - 'TimeRange.Pointer' -); +const TimeRangePointer: ConnectedComponent< + React.ComponentProps<'div'>, + typeof renderTimeRangePointer +> = toContextComponent(useTimeRangePointerProps, renderTimeRangePointer, 'TimeRange.Pointer'); // ============================================================================ // PROGRESS COMPONENT // ============================================================================ export const useTimeRangeProgressProps = ( - props: React.HTMLAttributes -): React.HTMLAttributes => { + props: React.ComponentProps<'div'>, + context: TimeRange.State +): TimeRangeRenderProps => { return { + 'data-orientation': context.orientation, ...props, style: { ...props.style, - width: 'var(--slider-fill, 0%)', + [context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-fill, 0%)', + [context.orientation === 'horizontal' ? 'height' : 'width']: '100%', + [context.orientation === 'horizontal' ? 'top' : 'bottom']: '0', position: 'absolute' as const, - height: '100%', }, }; }; type useTimeRangeProgressProps = typeof useTimeRangeProgressProps; -type TimeRangeProgressProps = ReturnType; -export const renderTimeRangeProgress = (props: TimeRangeProgressProps): JSX.Element => { +export const renderTimeRangeProgress = (props: TimeRangeRenderProps): JSX.Element => { return
; }; -const TimeRangeProgress: ConnectedComponent = - toContextComponent(useTimeRangeProgressProps, renderTimeRangeProgress, 'TimeRange.Progress'); +const TimeRangeProgress: ConnectedComponent< + React.ComponentProps<'div'>, + typeof renderTimeRangeProgress +> = toContextComponent(useTimeRangeProgressProps, renderTimeRangeProgress, 'TimeRange.Progress'); // ============================================================================ // EXPORTS diff --git a/packages/react/react/src/components/VolumeRange.tsx b/packages/react/react/src/components/VolumeRange.tsx index d60579a0..1f7584da 100644 --- a/packages/react/react/src/components/VolumeRange.tsx +++ b/packages/react/react/src/components/VolumeRange.tsx @@ -1,5 +1,4 @@ import type { ConnectedComponent } from '../utils/component-factory'; -import type { PropsWithChildren } from 'react'; import { useCallback, useMemo } from 'react'; @@ -9,19 +8,33 @@ import { shallowEqual, useMediaSelector, useMediaStore } from '@vjs-10/react-med import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory'; +export namespace VolumeRange { + export interface State { + volume: number; + muted: boolean; + volumeLevel: string; + requestVolumeChange: (volume: number) => void; + core: CoreVolumeRange; + orientation: 'horizontal' | 'vertical'; + } + + export interface Props extends React.ComponentProps<'div'> { + orientation?: 'horizontal' | 'vertical'; + } +} + +interface VolumeRangeRenderProps extends React.ComponentProps<'div'> { + 'data-orientation'?: 'horizontal' | 'vertical'; + 'data-muted'?: boolean; + 'data-volume-level'?: string; +} + // ============================================================================ // ROOT COMPONENT // ============================================================================ -export const useVolumeRangeRootState = ( - _props: any -): { - volume: number; - muted: boolean; - volumeLevel: string; - requestVolumeChange: (volume: number) => void; - core: CoreVolumeRange; -} => { +export const useVolumeRangeRootState = (props: VolumeRange.Props): VolumeRange.State => { + const { orientation = 'horizontal' } = props; const mediaStore = useMediaStore(); const mediaState = useMediaSelector(volumeRangeStateDefinition.stateTransform, shallowEqual); const mediaMethods = useMemo( @@ -33,46 +46,49 @@ export const useVolumeRangeRootState = ( return { ...mediaState, ...mediaMethods, + orientation, core, }; }; -export const useVolumeRangeRootProps = ( - props: PropsWithChildren<{ [k: string]: any }>, - state: ReturnType -) => { +export const useVolumeRangeRootProps = (props: VolumeRange.Props, state: VolumeRange.State): VolumeRangeRenderProps => { const { _fillWidth, _pointerWidth, _volumeText } = state.core.getState(); + const { children, className, id, style, orientation = 'horizontal' } = props; + return { ref: useCallback((el: HTMLDivElement) => { state.core?.attach(el); }, []), + id, role: 'slider', 'aria-label': 'Volume', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': _fillWidth, 'aria-valuetext': _volumeText, + 'aria-orientation': orientation, + 'data-orientation': orientation, 'data-muted': state.muted, 'data-volume-level': state.volumeLevel, + className, style: { - ...props.style, + ...style, '--slider-fill': `${_fillWidth.toFixed(3)}%`, '--slider-pointer': `${_pointerWidth.toFixed(3)}%`, - }, - ...props, - } as PropsWithChildren<{ [k: string]: any }>; + } as React.CSSProperties, + children, + }; }; type useVolumeRangeRootState = typeof useVolumeRangeRootState; type useVolumeRangeRootProps = typeof useVolumeRangeRootProps; -type VolumeRangeRootProps = ReturnType; -export const renderVolumeRangeRoot = (props: VolumeRangeRootProps): JSX.Element => { +export const renderVolumeRangeRoot = (props: VolumeRangeRenderProps): JSX.Element => { return
; }; -const VolumeRangeRoot: ConnectedComponent = toConnectedComponent( +const VolumeRangeRoot: ConnectedComponent = toConnectedComponent( useVolumeRangeRootState, useVolumeRangeRootProps, renderVolumeRangeRoot, @@ -84,89 +100,96 @@ const VolumeRangeRoot: ConnectedComponent>, - context: any -): PropsWithChildren> & { ref?: any } => { + props: React.ComponentProps<'div'>, + context: VolumeRange.State +): VolumeRangeRenderProps => { return { ref: useCallback((el: HTMLDivElement) => { context.core?.setState({ _trackElement: el }); }, []), + 'data-orientation': context.orientation, ...props, + style: { + ...props.style, + [context.orientation === 'horizontal' ? 'width' : 'height']: '100%', + }, }; }; type useVolumeRangeTrackProps = typeof useVolumeRangeTrackProps; -type VolumeRangeTrackProps = ReturnType; -export const renderVolumeRangeTrack = (props: VolumeRangeTrackProps): JSX.Element => { +export const renderVolumeRangeTrack = (props: VolumeRangeRenderProps): JSX.Element => { return
; }; -const VolumeRangeTrack: ConnectedComponent = toContextComponent( - useVolumeRangeTrackProps, - renderVolumeRangeTrack, - 'VolumeRange.Track' -); +const VolumeRangeTrack: ConnectedComponent< + React.ComponentProps<'div'>, + typeof renderVolumeRangeTrack +> = toContextComponent(useVolumeRangeTrackProps, renderVolumeRangeTrack, 'VolumeRange.Track'); // ============================================================================ // THUMB COMPONENT // ============================================================================ export const useVolumeRangeThumbProps = ( - props: React.HTMLAttributes -): React.HTMLAttributes => { + props: React.ComponentProps<'div'>, + context: VolumeRange.State +): VolumeRangeRenderProps => { return { + 'data-orientation': context.orientation, ...props, style: { ...props.style, - insetInlineStart: 'var(--slider-fill)', + [context.orientation === 'horizontal' ? 'insetInlineStart' : 'insetBlockEnd']: 'var(--slider-fill)', + [context.orientation === 'horizontal' ? 'top' : 'left']: '50%', + translate: context.orientation === 'horizontal' ? '-50% -50%' : '-50% 50%', position: 'absolute' as const, - top: '50%', - transform: 'translate(-50%, -50%)', }, }; }; type useVolumeRangeThumbProps = typeof useVolumeRangeThumbProps; -type VolumeRangeThumbProps = ReturnType; -export const renderVolumeRangeThumb = (props: VolumeRangeThumbProps): JSX.Element => { +export const renderVolumeRangeThumb = (props: VolumeRangeRenderProps): JSX.Element => { return
; }; -const VolumeRangeThumb: ConnectedComponent = toContextComponent( - useVolumeRangeThumbProps, - renderVolumeRangeThumb, - 'VolumeRange.Thumb' -); +const VolumeRangeThumb: ConnectedComponent< + React.ComponentProps<'div'>, + typeof renderVolumeRangeThumb +> = toContextComponent(useVolumeRangeThumbProps, renderVolumeRangeThumb, 'VolumeRange.Thumb'); // ============================================================================ // PROGRESS COMPONENT // ============================================================================ export const useVolumeRangeProgressProps = ( - props: React.HTMLAttributes -): React.HTMLAttributes => { + props: React.ComponentProps<'div'>, + context: VolumeRange.State +): VolumeRangeRenderProps => { return { + 'data-orientation': context.orientation, ...props, style: { ...props.style, - width: 'var(--slider-fill, 0%)', + [context.orientation === 'horizontal' ? 'width' : 'height']: 'var(--slider-fill, 0%)', + [context.orientation === 'horizontal' ? 'height' : 'width']: '100%', + [context.orientation === 'horizontal' ? 'top' : 'bottom']: '0', position: 'absolute' as const, - height: '100%', }, }; }; type useVolumeRangeProgressProps = typeof useVolumeRangeProgressProps; -type VolumeRangeProgressProps = ReturnType; -export const renderVolumeRangeProgress = (props: VolumeRangeProgressProps): JSX.Element => { +export const renderVolumeRangeProgress = (props: VolumeRangeRenderProps): JSX.Element => { return
; }; -const VolumeRangeProgress: ConnectedComponent = - toContextComponent(useVolumeRangeProgressProps, renderVolumeRangeProgress, 'VolumeRange.Progress'); +const VolumeRangeProgress: ConnectedComponent< + React.ComponentProps<'div'>, + typeof renderVolumeRangeProgress +> = toContextComponent(useVolumeRangeProgressProps, renderVolumeRangeProgress, 'VolumeRange.Progress'); // ============================================================================ // EXPORTS diff --git a/packages/react/react/src/skins/default/MediaSkinDefault.tsx b/packages/react/react/src/skins/default/MediaSkinDefault.tsx index 4325cddd..84303c4d 100644 --- a/packages/react/react/src/skins/default/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/default/MediaSkinDefault.tsx @@ -62,7 +62,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps - + diff --git a/packages/react/react/src/skins/default/styles.ts b/packages/react/react/src/skins/default/styles.ts index 6a80186e..de2cdc0d 100644 --- a/packages/react/react/src/skins/default/styles.ts +++ b/packages/react/react/src/skins/default/styles.ts @@ -127,8 +127,8 @@ const styles: MediaDefaultSkinStyles = { ), TimeControls: cn('flex-1 flex items-center gap-3 px-1.5'), TimeDisplay: cn('tabular-nums text-shadow-2xs shadow-black/50'), - TimeRangeRoot: cn('flex h-5 items-center flex-1 group/slider relative'), - TimeRangeTrack: cn('h-1 w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5'), + TimeRangeRoot: cn('flex [&[data-orientation="horizontal"]]:h-5 [&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20 items-center justify-center flex-1 group/slider relative'), + TimeRangeTrack: cn('[&[data-orientation="horizontal"]]:h-1 [&[data-orientation="vertical"]]:w-1 w-full relative select-none rounded-full bg-white/20 ring-1 ring-black/5'), TimeRangeProgress: cn('bg-white rounded-[inherit]'), // TODO: Work out what we want to do here. TimeRangePointer: cn('rounded-[inherit]'), @@ -139,8 +139,8 @@ const styles: MediaDefaultSkinStyles = { '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'), + VolumeRangeRoot: cn('flex [&[data-orientation="horizontal"]]:w-20 [&[data-orientation="horizontal"]]:h-5 [&[data-orientation="vertical"]]:w-5 [&[data-orientation="vertical"]]:h-20 items-center justify-center group/slider relative'), + VolumeRangeTrack: cn('[&[data-orientation="horizontal"]]:h-1 [&[data-orientation="vertical"]]:w-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',