feat: add range orientation to react components (#30)

This commit is contained in:
Wesley Luyten
2025-09-26 17:57:21 -05:00
committed by GitHub
parent 687b7655b0
commit 5e3cb8ad81
5 changed files with 159 additions and 111 deletions
+2 -2
View File
@@ -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 }
);
}
@@ -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<typeof useTimeRangeRootState>
) => {
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<useTimeRangeRootProps>;
export const renderTimeRangeRoot = (props: TimeRangeRootProps): JSX.Element => {
export const renderTimeRangeRoot = (props: TimeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const TimeRangeRoot: ConnectedComponent<TimeRangeRootProps, typeof renderTimeRangeRoot> = toConnectedComponent(
const TimeRangeRoot: ConnectedComponent<TimeRange.Props, typeof renderTimeRangeRoot> = toConnectedComponent(
useTimeRangeRootState,
useTimeRangeRootProps,
renderTimeRangeRoot,
@@ -80,25 +96,29 @@ const TimeRangeRoot: ConnectedComponent<TimeRangeRootProps, typeof renderTimeRan
// ============================================================================
export const useTimeRangeTrackProps = (
props: PropsWithChildren<Record<string, unknown>>,
context: any
): PropsWithChildren<Record<string, unknown>> & { 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<useTimeRangeTrackProps>;
export const renderTimeRangeTrack = (props: TimeRangeTrackProps): JSX.Element => {
export const renderTimeRangeTrack = (props: TimeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const TimeRangeTrack: ConnectedComponent<TimeRangeTrackProps, typeof renderTimeRangeTrack> = toContextComponent(
const TimeRangeTrack: ConnectedComponent<React.ComponentProps<'div'>, typeof renderTimeRangeTrack> = toContextComponent(
useTimeRangeTrackProps,
renderTimeRangeTrack,
'TimeRange.Track'
@@ -109,28 +129,29 @@ const TimeRangeTrack: ConnectedComponent<TimeRangeTrackProps, typeof renderTimeR
// ============================================================================
export const useTimeRangeThumbProps = (
props: React.HTMLAttributes<HTMLDivElement>
): React.HTMLAttributes<HTMLDivElement> => {
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<useTimeRangeThumbProps>;
export const renderTimeRangeThumb = (props: TimeRangeThumbProps): JSX.Element => {
export const renderTimeRangeThumb = (props: TimeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const TimeRangeThumb: ConnectedComponent<TimeRangeThumbProps, typeof renderTimeRangeThumb> = toContextComponent(
const TimeRangeThumb: ConnectedComponent<React.ComponentProps<'div'>, typeof renderTimeRangeThumb> = toContextComponent(
useTimeRangeThumbProps,
renderTimeRangeThumb,
'TimeRange.Thumb'
@@ -141,59 +162,63 @@ const TimeRangeThumb: ConnectedComponent<TimeRangeThumbProps, typeof renderTimeR
// ============================================================================
export const useTimeRangePointerProps = (
props: React.HTMLAttributes<HTMLDivElement>
): React.HTMLAttributes<HTMLDivElement> => {
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<useTimeRangePointerProps>;
export const renderTimeRangePointer = (props: TimeRangePointerProps): JSX.Element => {
export const renderTimeRangePointer = (props: TimeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const TimeRangePointer: ConnectedComponent<TimeRangePointerProps, typeof renderTimeRangePointer> = 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<HTMLDivElement>
): React.HTMLAttributes<HTMLDivElement> => {
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<useTimeRangeProgressProps>;
export const renderTimeRangeProgress = (props: TimeRangeProgressProps): JSX.Element => {
export const renderTimeRangeProgress = (props: TimeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const TimeRangeProgress: ConnectedComponent<TimeRangeProgressProps, typeof renderTimeRangeProgress> =
toContextComponent(useTimeRangeProgressProps, renderTimeRangeProgress, 'TimeRange.Progress');
const TimeRangeProgress: ConnectedComponent<
React.ComponentProps<'div'>,
typeof renderTimeRangeProgress
> = toContextComponent(useTimeRangeProgressProps, renderTimeRangeProgress, 'TimeRange.Progress');
// ============================================================================
// EXPORTS
@@ -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<typeof useVolumeRangeRootState>
) => {
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<useVolumeRangeRootProps>;
export const renderVolumeRangeRoot = (props: VolumeRangeRootProps): JSX.Element => {
export const renderVolumeRangeRoot = (props: VolumeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeRoot: ConnectedComponent<VolumeRangeRootProps, typeof renderVolumeRangeRoot> = toConnectedComponent(
const VolumeRangeRoot: ConnectedComponent<VolumeRange.Props, typeof renderVolumeRangeRoot> = toConnectedComponent(
useVolumeRangeRootState,
useVolumeRangeRootProps,
renderVolumeRangeRoot,
@@ -84,89 +100,96 @@ const VolumeRangeRoot: ConnectedComponent<VolumeRangeRootProps, typeof renderVol
// ============================================================================
export const useVolumeRangeTrackProps = (
props: PropsWithChildren<Record<string, unknown>>,
context: any
): PropsWithChildren<Record<string, unknown>> & { 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<useVolumeRangeTrackProps>;
export const renderVolumeRangeTrack = (props: VolumeRangeTrackProps): JSX.Element => {
export const renderVolumeRangeTrack = (props: VolumeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeTrack: ConnectedComponent<VolumeRangeTrackProps, typeof renderVolumeRangeTrack> = 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<HTMLDivElement>
): React.HTMLAttributes<HTMLDivElement> => {
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<useVolumeRangeThumbProps>;
export const renderVolumeRangeThumb = (props: VolumeRangeThumbProps): JSX.Element => {
export const renderVolumeRangeThumb = (props: VolumeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeThumb: ConnectedComponent<VolumeRangeThumbProps, typeof renderVolumeRangeThumb> = 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<HTMLDivElement>
): React.HTMLAttributes<HTMLDivElement> => {
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<useVolumeRangeProgressProps>;
export const renderVolumeRangeProgress = (props: VolumeRangeProgressProps): JSX.Element => {
export const renderVolumeRangeProgress = (props: VolumeRangeRenderProps): JSX.Element => {
return <div {...props} />;
};
const VolumeRangeProgress: ConnectedComponent<VolumeRangeProgressProps, typeof renderVolumeRangeProgress> =
toContextComponent(useVolumeRangeProgressProps, renderVolumeRangeProgress, 'VolumeRange.Progress');
const VolumeRangeProgress: ConnectedComponent<
React.ComponentProps<'div'>,
typeof renderVolumeRangeProgress
> = toContextComponent(useVolumeRangeProgressProps, renderVolumeRangeProgress, 'VolumeRange.Progress');
// ============================================================================
// EXPORTS
@@ -62,7 +62,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps
<VolumeOffIcon className={styles.VolumeOffIcon} />
</MuteButton>
<VolumeRange.Root className={styles.VolumeRangeRoot}>
<VolumeRange.Root className={styles.VolumeRangeRoot} orientation="vertical">
<VolumeRange.Track className={styles.VolumeRangeTrack}>
<VolumeRange.Progress className={styles.VolumeRangeProgress} />
</VolumeRange.Track>
@@ -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',