feat: make time range compound component (#10)

* feat: make time range compound component

* fix: add root ctx, use skin styles

* fix: improve time range

* fix: move more logic to root component

* fix: renaming

* fix: dragging and add memo to context value

* fix: improve time range styles
This commit is contained in:
Wesley Luyten
2025-09-15 11:27:40 -05:00
committed by GitHub
parent 597e79d7fc
commit 71a8dafd74
6 changed files with 424 additions and 73 deletions
+2 -1
View File
@@ -17,6 +17,7 @@
],
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "echo \"No tests yet\"",
"clean": "rm -rf dist"
},
@@ -51,4 +52,4 @@
"publishConfig": {
"access": "public"
}
}
}
+335 -69
View File
@@ -1,105 +1,371 @@
import {
shallowEqual,
useMediaSelector,
useMediaStore,
} from '@vjs-10/react-media-store';
import * as React from 'react';
import { toConnectedComponent } from '../utils/component-factory';
import { shallowEqual, useMediaSelector, useMediaStore } from '@vjs-10/react-media-store';
import { timeRangeStateDefinition } from '@vjs-10/media-store';
export const useTimeRangeState = (_props: any) => {
import { toConnectedComponent, toContextComponent } from '../utils/component-factory';
const formatTime = (time: number): string => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// Utility functions for pointer position and seek time 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 calculateSeekTimeFromRatio = (ratio: number, duration: number): number => {
return (ratio / 100) * duration;
};
const calculateSeekTimeFromPointerEvent = (
e: React.PointerEvent<HTMLDivElement>,
duration: number
): number => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = calculatePointerRatio(e.clientX, rect);
return calculateSeekTimeFromRatio(ratio, duration);
};
// ============================================================================
// ROOT COMPONENT
// ============================================================================
interface TimeRangeRootContextValue {
currentTime: number;
duration: number;
requestSeek: (time: number) => void;
pointerPosition: number | null;
setPointerPosition: (position: number | null) => void;
hovering: boolean;
setHovering: (hovering: boolean) => void;
dragging: boolean;
setDragging: (dragging: boolean) => void;
setTrackRef: (ref: HTMLDivElement | null) => void;
}
const TimeRangeRootContext = React.createContext<TimeRangeRootContextValue | null>(null);
export const useTimeRangeRootContext = (): TimeRangeRootContextValue => {
const context = React.useContext(TimeRangeRootContext);
if (!context) {
throw new Error('useTimeRangeRootContext must be used within a TimeRange.Root component');
}
return context;
};
export const useTimeRangeRootState = (_props: any) => {
const mediaStore = useMediaStore();
/** @TODO Fix type issues with hooks (CJP) */
const mediaState = useMediaSelector(
timeRangeStateDefinition.stateTransform,
shallowEqual,
);
const mediaState = useMediaSelector(timeRangeStateDefinition.stateTransform, shallowEqual);
const methods = React.useMemo(
() => timeRangeStateDefinition.createRequestMethods(mediaStore.dispatch),
[mediaStore],
[mediaStore]
);
const { requestSeek } = 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 {
currentTime: mediaState.currentTime,
duration: mediaState.duration,
requestSeek: methods.requestSeek,
requestSeek,
pointerPosition,
setPointerPosition,
hovering,
setHovering,
dragging,
setDragging,
trackRef,
setTrackRef,
} as const;
};
export type useTimeRangeState = typeof useTimeRangeState;
export type TimeRangeState = ReturnType<useTimeRangeState>;
export const useTimeRangeProps = (
export const useTimeRangeRootProps = (
props: React.PropsWithChildren<{ [k: string]: any }>,
state: ReturnType<typeof useTimeRangeState>,
state: ReturnType<typeof useTimeRangeRootState>
) => {
const ratio =
state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0;
// When dragging, use pointer position for immediate feedback; otherwise use current time
const sliderFill =
state.dragging && state.pointerPosition !== null
? state.pointerPosition
: state.duration > 0
? (state.currentTime / state.duration) * 100
: 0;
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
state.setDragging(true);
const seekTime = calculateSeekTimeFromPointerEvent(e, state.duration);
state.requestSeek(seekTime);
// Capture pointer events to ensure we receive move and up events even if pointer leaves element
e.currentTarget.setPointerCapture(e.pointerId);
},
[state.setDragging, state.requestSeek, state.duration]
);
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 seekTime = calculateSeekTimeFromRatio(ratio, state.duration);
state.requestSeek(seekTime);
}
},
[state.trackRef, state.setPointerPosition, state.dragging, state.requestSeek, state.duration]
);
const handlePointerUp = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId);
if (state.dragging && state.trackRef && state.pointerPosition !== null) {
const seekTime = calculateSeekTimeFromRatio(state.pointerPosition, state.duration);
state.requestSeek(seekTime);
}
state.setDragging(false);
},
[
state.dragging,
state.trackRef,
state.pointerPosition,
state.requestSeek,
state.duration,
state.setDragging,
]
);
const handlePointerEnter = React.useCallback(() => {
state.setHovering(true);
}, [state.setHovering]);
const handlePointerLeave = React.useCallback(() => {
state.setHovering(false);
}, [state.setHovering]);
const currentTimeText = formatTime(state.currentTime);
const durationText = formatTime(state.duration);
const baseProps: Record<string, any> = {
/** input properties */
type: 'range',
min: '0',
max: '100',
step: '0.1',
value: ratio,
/** aria attributes/props */
return {
role: 'slider',
'aria-label': 'Seek',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': sliderFill,
'aria-valuetext': `${currentTimeText} of ${durationText}`,
/** data attributes */
'data-current-time': state.currentTime,
'data-duration': state.duration,
/** external props spread last to allow for overriding */
style: {
...props.style,
'--slider-fill': `${Math.round(sliderFill)}%`,
'--slider-pointer':
state.hovering && state.pointerPosition !== null
? `${Math.round(state.pointerPosition)}%`
: '0%',
},
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
...props,
};
return baseProps;
} as React.PropsWithChildren<{ [k: string]: any }>;
};
export type useTimeRangeProps = typeof useTimeRangeProps;
type TimeRangeProps = ReturnType<useTimeRangeProps>;
type useTimeRangeRootState = typeof useTimeRangeRootState;
type useTimeRangeRootProps = typeof useTimeRangeRootProps;
type TimeRangeRootState = ReturnType<useTimeRangeRootState>;
type TimeRangeRootProps = ReturnType<useTimeRangeRootProps>;
export const renderTimeRangeRoot = (props: TimeRangeRootProps, state: TimeRangeRootState) => {
const contextValue: TimeRangeRootContextValue = React.useMemo(
() => ({
currentTime: state.currentTime,
duration: state.duration,
requestSeek: state.requestSeek,
pointerPosition: state.pointerPosition,
setPointerPosition: state.setPointerPosition,
hovering: state.hovering,
setHovering: state.setHovering,
dragging: state.dragging,
setDragging: state.setDragging,
setTrackRef: state.setTrackRef,
}),
[
state.currentTime,
state.duration,
state.requestSeek,
state.pointerPosition,
state.setPointerPosition,
state.hovering,
state.setHovering,
state.dragging,
state.setDragging,
state.setTrackRef,
]
);
/**
* @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 renderTimeRange = (
props: TimeRangeProps,
state: TimeRangeState,
) => {
return (
<input
{...props}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
/** @ts-ignore */
if (props.disabled) return;
const ratio = parseFloat(e.target.value) / 100;
const seekTime = ratio * state.duration;
state.requestSeek(seekTime);
}}
/>
<TimeRangeRootContext.Provider value={contextValue}>
<div style={props.style} {...props}>
{props.children}
</div>
</TimeRangeRootContext.Provider>
);
};
export type renderTimeRange = typeof renderTimeRange;
/**
* @TODO When implementing compound components, this function may need to be swapped out, modified, or augmented in some way or another. (CJP)
*/
export const TimeRange = toConnectedComponent(
useTimeRangeState,
useTimeRangeProps,
renderTimeRange,
'TimeRange',
const TimeRangeRoot = toConnectedComponent(
useTimeRangeRootState,
useTimeRangeRootProps,
renderTimeRangeRoot,
'TimeRange.Root'
);
// ============================================================================
// TRACK COMPONENT
// ============================================================================
export const useTimeRangeTrackProps = (props: React.PropsWithChildren<{ [k: string]: any }>) => {
const { setTrackRef } = useTimeRangeRootContext();
return {
ref: setTrackRef,
...props,
};
};
type useTimeRangeTrackProps = typeof useTimeRangeTrackProps;
type TimeRangeTrackProps = ReturnType<useTimeRangeTrackProps>;
export const renderTimeRangeTrack = (props: TimeRangeTrackProps) => {
return <div {...props} />;
};
const TimeRangeTrack = toContextComponent(
useTimeRangeTrackProps,
renderTimeRangeTrack,
'TimeRange.Track'
);
// ============================================================================
// THUMB COMPONENT
// ============================================================================
export const useTimeRangeThumbProps = (props: React.HTMLAttributes<HTMLDivElement>) => {
return {
...props,
style: {
...props.style,
insetInlineStart: 'var(--slider-fill)',
position: 'absolute' as const,
top: '50%',
transform: 'translate(-50%, -50%)',
},
};
};
type useTimeRangeThumbProps = typeof useTimeRangeThumbProps;
type TimeRangeThumbProps = ReturnType<useTimeRangeThumbProps>;
export const renderTimeRangeThumb = (props: TimeRangeThumbProps) => {
return <div {...props} />;
};
const TimeRangeThumb = toContextComponent(
useTimeRangeThumbProps,
renderTimeRangeThumb,
'TimeRange.Thumb'
);
// ============================================================================
// POINTER COMPONENT
// ============================================================================
export const useTimeRangePointerProps = (props: React.HTMLAttributes<HTMLDivElement>) => {
return {
...props,
style: {
...props.style,
width: 'var(--slider-pointer, 0%)',
position: 'absolute' as const,
height: '100%',
},
};
};
type useTimeRangePointerProps = typeof useTimeRangePointerProps;
type TimeRangePointerProps = ReturnType<useTimeRangePointerProps>;
export const renderTimeRangePointer = (props: TimeRangePointerProps) => {
return <div {...props} />;
};
const TimeRangePointer = toContextComponent(
useTimeRangePointerProps,
renderTimeRangePointer,
'TimeRange.Pointer'
);
// ============================================================================
// PROGRESS COMPONENT
// ============================================================================
export const useTimeRangeProgressProps = (props: React.HTMLAttributes<HTMLDivElement>) => {
return {
...props,
style: {
...props.style,
width: 'var(--slider-fill, 0%)',
position: 'absolute' as const,
height: '100%',
},
};
};
type useTimeRangeProgressProps = typeof useTimeRangeProgressProps;
type TimeRangeProgressProps = ReturnType<useTimeRangeProgressProps>;
export const renderTimeRangeProgress = (props: TimeRangeProgressProps) => {
return <div {...props} />;
};
const TimeRangeProgress = toContextComponent(
useTimeRangeProgressProps,
renderTimeRangeProgress,
'TimeRange.Progress'
);
// ============================================================================
// EXPORTS
// ============================================================================
export const TimeRange = Object.assign(
{},
{
Root: TimeRangeRoot,
Track: TimeRangeTrack,
Thumb: TimeRangeThumb,
Pointer: TimeRangePointer,
Progress: TimeRangeProgress,
}
) as {
Root: typeof TimeRangeRoot;
Track: typeof TimeRangeTrack;
Thumb: typeof TimeRangeThumb;
Pointer: typeof TimeRangePointer;
Progress: typeof TimeRangeProgress;
};
export default TimeRange;
@@ -36,7 +36,13 @@ export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({
// showRemaining
className={styles.TimeDisplay}
/>
<TimeRange className={styles.TimeRange} />
<TimeRange.Root className={styles.TimeRangeRoot}>
<TimeRange.Track className={styles.TimeRangeTrack}>
<TimeRange.Progress className={styles.TimeRangeProgress} />
<TimeRange.Pointer className={styles.TimeRangePointer} />
</TimeRange.Track>
<TimeRange.Thumb className={styles.TimeRangeThumb} />
</TimeRange.Root>
<DurationDisplay className={styles.TimeDisplay} />
{/* @ts-ignore */}
<MuteButton className={`${styles.Button} ${styles.MediaMuteButton}`}>
@@ -107,3 +107,41 @@
display: inline-block;
}
}
/* TimeRange Component Styles */
.TimeRangeRoot {
display: flex;
align-items: center;
position: relative;
min-width: 100px;
width: 100%;
padding-block: .75rem;
margin: 0 .5rem;
}
.TimeRangeTrack {
position: relative;
width: 100%;
height: .375rem;
background-color: #e0e0e0;
border-radius: .25rem;
overflow: hidden;
}
.TimeRangeThumb {
width: .75rem;
height: .75rem;
background-color: #fff;
border-radius: 50%;
pointer-events: none;
}
.TimeRangePointer {
background-color: rgba(255, 255, 255, .5);
pointer-events: none;
}
.TimeRangeProgress {
background-color: #007bff;
border-radius: inherit;
}
@@ -55,4 +55,42 @@ export const toConnectedComponent = <
export type ConnectedComponent<
TProps extends Record<string, any>,
TRenderFn extends RenderFn<any, any>
> = React.FC<TProps & { render?: TRenderFn }>;
> = React.FC<TProps & { render?: TRenderFn }>;
/**
* Factory function to create context-based components that don't use toConnectedComponent
* These components rely on context provided by a parent component.
*
* @param usePropsHook - Hook that enhances props with context-derived values
* @param defaultRender - Default render function for the component
* @param displayName - Display name for React DevTools
* @returns Context-based component with customizable render prop
*/
export const toContextComponent = <
TProps extends Record<string, any>,
TResultProps extends Record<string, any>,
TRenderFn extends (props: TResultProps) => React.ReactElement
>(
usePropsHook: (props: TProps) => TResultProps,
defaultRender: TRenderFn,
displayName: string,
) => {
const ContextComponent = ({
render = defaultRender,
...props
}: TProps & { render?: TRenderFn }) => {
const contextProps = usePropsHook(props as TProps);
return render(contextProps);
};
ContextComponent.displayName = displayName;
return ContextComponent;
};
/**
* Type helper to infer the context component type from the factory
*/
export type ContextComponent<
TProps extends Record<string, any>,
TRenderFn extends (props: any) => React.ReactElement
> = React.FC<TProps & { render?: TRenderFn }>;
+3 -1
View File
@@ -1,5 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
@@ -8,6 +9,7 @@
"dev": {
"cache": false,
"persistent": true,
"interactive": false,
"dependsOn": ["^build"]
},
"clean": {
@@ -24,4 +26,4 @@
"outputs": []
}
}
}
}