Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 9 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,26 @@ export default antfu(
...jsxA11y.configs.recommended.rules,
},
},
// TypeScript files
{
files: ['**/*.test.{ts,tsx}'],
files: ['**/*.{ts,tsx}'],
rules: {
'vitest/prefer-lowercase-title': 'off',
'ts/no-namespace': 'off',
},
},
// Test files
{
files: ['**/*.md'],
files: ['**/*.test.{ts,tsx}'],
rules: {
'style/max-len': 'off',
'vitest/prefer-lowercase-title': 'off',
},
},
// Markdown files
{
files: ['**/*.md/**'],
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',
Expand Down
15 changes: 10 additions & 5 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
'use client';

// Media
export { Video, type VideoProps } from './media/video';

// Store
export * from '@videojs/store/react';
138 changes: 138 additions & 0 deletions packages/react/src/media/tests/video.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 60 additions & 0 deletions packages/react/src/media/video.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Loading