diff --git a/packages/react/react/package.json b/packages/react/react/package.json index 6071f5bf..bd4b1cba 100644 --- a/packages/react/react/package.json +++ b/packages/react/react/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/packages/react/react/src/components/TimeRange.tsx b/packages/react/react/src/components/TimeRange.tsx index 8fa7732b..8357ad6d 100644 --- a/packages/react/react/src/components/TimeRange.tsx +++ b/packages/react/react/src/components/TimeRange.tsx @@ -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, + 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(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(null); + const [hovering, setHovering] = React.useState(false); + const [dragging, setDragging] = React.useState(false); + const [trackRef, setTrackRef] = React.useState(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; - -export const useTimeRangeProps = ( +export const useTimeRangeRootProps = ( props: React.PropsWithChildren<{ [k: string]: any }>, - state: ReturnType, + state: ReturnType ) => { - 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) => { + 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) => { + 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 = { - /** 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; +type useTimeRangeRootState = typeof useTimeRangeRootState; +type useTimeRangeRootProps = typeof useTimeRangeRootProps; +type TimeRangeRootState = ReturnType; +type TimeRangeRootProps = ReturnType; + +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 ( - ) => { - /** @ts-ignore */ - if (props.disabled) return; - const ratio = parseFloat(e.target.value) / 100; - const seekTime = ratio * state.duration; - state.requestSeek(seekTime); - }} - /> + +
+ {props.children} +
+
); }; -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; + +export const renderTimeRangeTrack = (props: TimeRangeTrackProps) => { + return
; +}; + +const TimeRangeTrack = toContextComponent( + useTimeRangeTrackProps, + renderTimeRangeTrack, + 'TimeRange.Track' +); + +// ============================================================================ +// THUMB COMPONENT +// ============================================================================ + +export const useTimeRangeThumbProps = (props: React.HTMLAttributes) => { + 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; + +export const renderTimeRangeThumb = (props: TimeRangeThumbProps) => { + return
; +}; + +const TimeRangeThumb = toContextComponent( + useTimeRangeThumbProps, + renderTimeRangeThumb, + 'TimeRange.Thumb' +); + +// ============================================================================ +// POINTER COMPONENT +// ============================================================================ + +export const useTimeRangePointerProps = (props: React.HTMLAttributes) => { + return { + ...props, + style: { + ...props.style, + width: 'var(--slider-pointer, 0%)', + position: 'absolute' as const, + height: '100%', + }, + }; +}; + +type useTimeRangePointerProps = typeof useTimeRangePointerProps; +type TimeRangePointerProps = ReturnType; + +export const renderTimeRangePointer = (props: TimeRangePointerProps) => { + return
; +}; + +const TimeRangePointer = toContextComponent( + useTimeRangePointerProps, + renderTimeRangePointer, + 'TimeRange.Pointer' +); + +// ============================================================================ +// PROGRESS COMPONENT +// ============================================================================ + +export const useTimeRangeProgressProps = (props: React.HTMLAttributes) => { + return { + ...props, + style: { + ...props.style, + width: 'var(--slider-fill, 0%)', + position: 'absolute' as const, + height: '100%', + }, + }; +}; + +type useTimeRangeProgressProps = typeof useTimeRangeProgressProps; +type TimeRangeProgressProps = ReturnType; + +export const renderTimeRangeProgress = (props: TimeRangeProgressProps) => { + return
; +}; + +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; diff --git a/packages/react/react/src/skins/MediaSkinDefault.tsx b/packages/react/react/src/skins/MediaSkinDefault.tsx index fedc45ef..c949e5bc 100644 --- a/packages/react/react/src/skins/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/MediaSkinDefault.tsx @@ -36,7 +36,13 @@ export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({ // showRemaining className={styles.TimeDisplay} /> - + + + + + + + {/* @ts-ignore */} diff --git a/packages/react/react/src/skins/styles.module.css b/packages/react/react/src/skins/styles.module.css index b9ebcfbd..ce4e645d 100644 --- a/packages/react/react/src/skins/styles.module.css +++ b/packages/react/react/src/skins/styles.module.css @@ -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; +} diff --git a/packages/react/react/src/utils/component-factory.tsx b/packages/react/react/src/utils/component-factory.tsx index 1eab1b82..894f2958 100644 --- a/packages/react/react/src/utils/component-factory.tsx +++ b/packages/react/react/src/utils/component-factory.tsx @@ -55,4 +55,42 @@ export const toConnectedComponent = < export type ConnectedComponent< TProps extends Record, TRenderFn extends RenderFn -> = React.FC; \ No newline at end of file +> = React.FC; + +/** + * 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, + TResultProps extends Record, + 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, + TRenderFn extends (props: any) => React.ReactElement +> = React.FC; diff --git a/turbo.json b/turbo.json index 27181772..292fbcf1 100644 --- a/turbo.json +++ b/turbo.json @@ -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": [] } } -} \ No newline at end of file +}