feat(react): add video component and utility hooks (#293)

This commit is contained in:
rahim
2026-01-06 23:01:36 +11:00
committed by GitHub
parent 648aae7e31
commit a71a280bdc
10 changed files with 476 additions and 18 deletions
+6
View File
@@ -1 +1,7 @@
'use client';
// Media
export { Video, type VideoProps } from './media/video';
// Store
export * from '@videojs/store/react';
@@ -0,0 +1,138 @@
import { render } from '@testing-library/react';
import { createSlice } from '@videojs/store';
import { createStore } from '@videojs/store/react';
import { describe, expect, it, vi } from 'vitest';
import { Video } from '../video';
describe('video', () => {
class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
const mockSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: () => {},
request: {},
});
function createTestStore() {
return createStore({ slices: [mockSlice] });
}
it('renders a video element', () => {
const { Provider } = createTestStore();
const { container } = render(
<Provider>
<Video data-testid="test-video" />
</Provider>,
);
const video = container.querySelector('video');
expect(video).toBeTruthy();
expect(video?.getAttribute('data-testid')).toBe('test-video');
});
it('passes props to video element', () => {
const { Provider } = createTestStore();
const { container } = render(
<Provider>
<Video src="test.mp4" controls autoPlay playsInline />
</Provider>,
);
const video = container.querySelector('video') as HTMLVideoElement;
expect(video?.getAttribute('src')).toBe('test.mp4');
expect(video?.hasAttribute('controls')).toBe(true);
expect(video?.hasAttribute('autoplay')).toBe(true);
// playsInline becomes playsinline attribute
expect(video?.hasAttribute('playsinline')).toBe(true);
});
it('renders children', () => {
const { Provider } = createTestStore();
const { container } = render(
<Provider>
<Video>
<source src="test.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" />
</Video>
</Provider>,
);
const video = container.querySelector('video');
expect(video?.querySelector('source')).toBeTruthy();
expect(video?.querySelector('track')).toBeTruthy();
});
it('attaches video to store on mount', () => {
const { Provider, useStore } = createTestStore();
let attachCalled = false;
function TestComponent() {
const store = useStore();
// Spy on attach
const originalAttach = store.attach.bind(store);
store.attach = (target) => {
attachCalled = true;
return originalAttach(target);
};
return <Video />;
}
render(
<Provider>
<TestComponent />
</Provider>,
);
expect(attachCalled).toBe(true);
});
it('works with external ref', () => {
const { Provider } = createTestStore();
let capturedElement: HTMLVideoElement | null = null;
function TestComponent() {
return (
<Video
ref={(el) => {
capturedElement = el;
}}
/>
);
}
render(
<Provider>
<TestComponent />
</Provider>,
);
expect(capturedElement).toBeInstanceOf(HTMLVideoElement);
});
it('throws when used outside Provider', () => {
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
render(<Video />);
}).toThrow('useStoreContext must be used within a Provider');
consoleSpy.mockRestore();
});
});
+60
View File
@@ -0,0 +1,60 @@
'use client';
import type { Ref, VideoHTMLAttributes } from 'react';
import { useStoreContext } from '@videojs/store/react';
import { useCallback } from 'react';
import { useComposedRefs } from '../utils/use-composed-refs';
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
ref?: Ref<HTMLVideoElement> | React.RefObject<HTMLVideoElement>;
}
/**
* Video element that automatically attaches to the nearest store context.
*
* Must be used within a Provider created by `createStore()`.
*
* @example
* ```tsx
* import { createStore, media } from '@videojs/react';
*
* const { Provider } = createStore({
* slices: media.all
* });
*
* function App() {
* return (
* <Provider>
* <Video src="video.mp4" controls />
* </Provider>
* );
* }
* ```
*/
export function Video({ children, ref: refProp, ...props }: VideoProps): React.JSX.Element {
const store = useStoreContext();
const attachRef = useCallback(
(el: HTMLVideoElement): (() => void) | void => {
if (!el) return;
return store.attach(el);
},
[store],
);
const ref = useComposedRefs(refProp, attachRef);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption -- captions can be passed via children
<video ref={ref} {...props}>
{children}
</video>
);
}
export namespace Video {
export type Props = VideoProps;
}
@@ -0,0 +1,127 @@
import type { MutableRefObject, RefObject } from 'react';
import { render } from '@testing-library/react';
import { useRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { composeRefs, useComposedRefs } from '../use-composed-refs';
// Helper to create a mutable ref object for testing (without using deprecated createRef)
function createMutableRef<T>(initialValue: T | null = null): MutableRefObject<T | null> {
return { current: initialValue };
}
describe('composeRefs', () => {
it('sets value on callback ref', () => {
const callbackRef = vi.fn();
const composed = composeRefs(callbackRef);
composed('test-value');
expect(callbackRef).toHaveBeenCalledWith('test-value');
});
it('sets value on RefObject', () => {
const refObject = createMutableRef<string>();
const composed = composeRefs(refObject);
composed('test-value');
expect(refObject.current).toBe('test-value');
});
it('sets value on multiple refs', () => {
const callbackRef = vi.fn();
const refObject = createMutableRef<string>();
const composed = composeRefs(callbackRef, refObject);
composed('test-value');
expect(callbackRef).toHaveBeenCalledWith('test-value');
expect(refObject.current).toBe('test-value');
});
it('handles undefined refs', () => {
const callbackRef = vi.fn();
const composed = composeRefs(undefined, callbackRef, undefined);
composed('test-value');
expect(callbackRef).toHaveBeenCalledWith('test-value');
});
it('returns cleanup function when callback ref returns one', () => {
const cleanup = vi.fn();
const callbackRef = vi.fn().mockReturnValue(cleanup);
const composed = composeRefs(callbackRef);
const returnedCleanup = composed('test-value') as (() => void) | void;
expect(returnedCleanup).toBeTypeOf('function');
if (typeof returnedCleanup === 'function') {
returnedCleanup();
}
expect(cleanup).toHaveBeenCalled();
});
it('clears RefObject on cleanup', () => {
const cleanup = vi.fn();
const callbackRef = vi.fn().mockReturnValue(cleanup);
const refObject = createMutableRef<string>();
const composed = composeRefs(callbackRef, refObject);
composed('test-value');
expect(refObject.current).toBe('test-value');
const returnedCleanup = composed('test-value') as (() => void) | void;
if (typeof returnedCleanup === 'function') {
returnedCleanup();
}
expect(refObject.current).toBeNull();
});
});
describe('useComposedRefs', () => {
it('returns a stable callback ref', () => {
let composedRef1: ((value: HTMLDivElement | null) => void) | null = null;
let composedRef2: ((value: HTMLDivElement | null) => void) | null = null;
function TestComponent() {
const ref1 = useRef<HTMLDivElement>(null);
const ref2 = useRef<HTMLDivElement>(null);
const composed = useComposedRefs(ref1, ref2);
if (!composedRef1) {
composedRef1 = composed;
} else {
composedRef2 = composed;
}
return <div ref={composed}>Test</div>;
}
const { rerender } = render(<TestComponent />);
rerender(<TestComponent />);
// Same refs should produce same composed ref
expect(composedRef1).toBe(composedRef2);
});
it('works with forwardRef pattern', () => {
const externalRef = createMutableRef<HTMLDivElement>();
function TestComponent({ forwardedRef }: { forwardedRef: RefObject<HTMLDivElement | null> }) {
const internalRef = useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(forwardedRef, internalRef);
return <div ref={composedRef}>Test</div>;
}
render(<TestComponent forwardedRef={externalRef} />);
expect(externalRef.current).toBeInstanceOf(HTMLDivElement);
});
});
@@ -0,0 +1,64 @@
'use client';
import type { Ref, RefCallback } from 'react';
import { useCallback } from 'react';
type PossibleRef<T> = Ref<T> | undefined;
/**
* Set a given ref to a given value.
*
* Handles both callback refs and RefObject(s).
*
* @returns Cleanup function if the ref callback returned one (React 19+)
*/
function setRef<T>(ref: PossibleRef<T>, value: T): (() => void) | void | undefined {
if (typeof ref === 'function') {
return ref(value);
} else if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* Compose multiple refs into a single callback ref.
*
* @example
* ```tsx
* const composedRef = composeRefs(ref1, ref2, ref3);
* return <div ref={composedRef} />;
* ```
*/
export function composeRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T> {
return (node): (() => void) | void => {
const cleanups = refs.map(ref => setRef(ref, node));
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === 'function') {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
};
}
/**
* Hook that composes multiple refs into a single callback ref.
*
* Memoized for stable reference.
*
* @example
* ```tsx
* const composedRef = useComposedRefs(forwardedRef, localRef);
* return <div ref={composedRef} />;
* ```
*/
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T> {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback(composeRefs(...refs), refs);
}