mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(react): add video component and utility hooks (#293)
This commit is contained in:
@@ -206,6 +206,40 @@ Before writing new helpers, check `@videojs/utils` for existing utilities.
|
||||
| Type guards | `is*` | `isStoreError(error)` |
|
||||
| Factory functions | `create*` | `createQueue()`, `createSlice()` |
|
||||
|
||||
### Component/Hook Namespace Pattern
|
||||
|
||||
Use namespaces to co-locate Props and Result types with components/hooks:
|
||||
|
||||
```tsx
|
||||
// Component with Props namespace
|
||||
export function Video({ src, ...props }: VideoProps): JSX.Element {
|
||||
// ...
|
||||
}
|
||||
|
||||
export namespace Video {
|
||||
export type Props = VideoProps;
|
||||
}
|
||||
|
||||
// Hook with Result namespace
|
||||
export function useMutation(name: string): MutationResult {
|
||||
// ...
|
||||
}
|
||||
|
||||
export namespace useMutation {
|
||||
export type Result = MutationResult;
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```tsx
|
||||
// Props type via namespace
|
||||
const props: Video.Props = { src: 'video.mp4' };
|
||||
|
||||
// Result type via namespace
|
||||
const mutation: useMutation.Result = useMutation('play');
|
||||
```
|
||||
|
||||
### Type Guards
|
||||
|
||||
Always return `value is Type` for proper type narrowing:
|
||||
@@ -332,11 +366,11 @@ JSDoc should add value, not restate what TypeScript already shows:
|
||||
* @param callback - The callback to invoke
|
||||
* @returns A cleanup function
|
||||
*/
|
||||
export function animationFrame(callback: FrameRequestCallback): () => void
|
||||
export function animationFrame(callback: FrameRequestCallback): () => void;
|
||||
|
||||
// Good
|
||||
/** Request an animation frame with cleanup. */
|
||||
export function animationFrame(callback: FrameRequestCallback): () => void
|
||||
export function animationFrame(callback: FrameRequestCallback): () => void;
|
||||
```
|
||||
|
||||
**Single JSDoc for overloads** — Document the first overload only:
|
||||
|
||||
+10
-6
@@ -46,22 +46,26 @@ export default antfu(
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
// TypeScript files
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'ts/no-namespace': 'off',
|
||||
},
|
||||
},
|
||||
// Test files
|
||||
{
|
||||
files: ['**/*.test.{ts,tsx}'],
|
||||
rules: {
|
||||
'vitest/prefer-lowercase-title': 'off',
|
||||
},
|
||||
},
|
||||
// Markdown files
|
||||
{
|
||||
files: ['**/*.md'],
|
||||
rules: {
|
||||
'style/max-len': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.md/**'],
|
||||
rules: {
|
||||
// Disable rules that conflict with documentation code examples in markdown
|
||||
'style/max-len': 'off',
|
||||
'ts/no-unsafe-function-type': 'off',
|
||||
'ts/method-signature-style': 'off',
|
||||
'node/handle-callback-err': 'off',
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch ./src",
|
||||
"dev": "pnpm run build:watch",
|
||||
"test": "echo \"No tests yet\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf dist types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -40,10 +41,14 @@
|
||||
"@videojs/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"react": "^18.0.0",
|
||||
"tsdown": "^0.15.12",
|
||||
"typescript": "^5.9.3"
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tsdown": "^0.15.9",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
});
|
||||
Generated
+17
-5
@@ -368,18 +368,30 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
devDependencies:
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@types/react':
|
||||
specifier: ^18.0.0
|
||||
version: 18.3.27
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.7
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
react:
|
||||
specifier: ^18.0.0
|
||||
version: 18.3.1
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.3
|
||||
react-dom:
|
||||
specifier: ^19.2.1
|
||||
version: 19.2.3(react@19.2.3)
|
||||
tsdown:
|
||||
specifier: ^0.15.12
|
||||
specifier: ^0.15.9
|
||||
version: 0.15.12(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/store:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user