From a71a280bdc0299cd78eaa200be4784f6a7720f14 Mon Sep 17 00:00:00 2001 From: rahim Date: Tue, 6 Jan 2026 23:01:36 +1100 Subject: [PATCH] feat(react): add video component and utility hooks (#293) --- CLAUDE.md | 38 ++++- eslint.config.mjs | 16 +- packages/react/package.json | 15 +- packages/react/src/index.ts | 6 + packages/react/src/media/tests/video.test.tsx | 138 ++++++++++++++++++ packages/react/src/media/video.tsx | 60 ++++++++ .../utils/tests/use-composed-refs.test.tsx | 127 ++++++++++++++++ packages/react/src/utils/use-composed-refs.ts | 64 ++++++++ packages/react/vitest.config.ts | 8 + pnpm-lock.yaml | 22 ++- 10 files changed, 476 insertions(+), 18 deletions(-) create mode 100644 packages/react/src/media/tests/video.test.tsx create mode 100644 packages/react/src/media/video.tsx create mode 100644 packages/react/src/utils/tests/use-composed-refs.test.tsx create mode 100644 packages/react/src/utils/use-composed-refs.ts create mode 100644 packages/react/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 13128d2c..fe440d18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/eslint.config.mjs b/eslint.config.mjs index 69ef3472..333cb183 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/packages/react/package.json b/packages/react/package.json index 0b6b66b3..aa4de8bb 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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" diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ef4af0a0..b827556f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1,7 @@ 'use client'; + +// Media +export { Video, type VideoProps } from './media/video'; + +// Store +export * from '@videojs/store/react'; diff --git a/packages/react/src/media/tests/video.test.tsx b/packages/react/src/media/tests/video.test.tsx new file mode 100644 index 00000000..3f03039b --- /dev/null +++ b/packages/react/src/media/tests/video.test.tsx @@ -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()({ + 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( + + , + ); + + 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( + + , + ); + + 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( + + + , + ); + + 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