feat(core): add play button component (#383)

This commit is contained in:
rahim
2026-02-04 00:33:14 +11:00
committed by GitHub
parent 9fa172e8a4
commit 9cfab264d8
41 changed files with 2083 additions and 38 deletions
+113
View File
@@ -0,0 +1,113 @@
'use client';
import type { ComponentPropsWithRef, CSSProperties, ElementType, SyntheticEvent } from 'react';
type Props<T extends ElementType = ElementType> = ComponentPropsWithRef<T>;
/**
* Check if a key is an event handler key (on* with capital letter).
*/
function isEventHandlerKey(key: string): boolean {
return (
key.charCodeAt(0) === 111 /* o */ &&
key.charCodeAt(1) === 110 /* n */ &&
key.charCodeAt(2) >= 65 /* A */ &&
key.charCodeAt(2) <= 90 /* Z */
);
}
/**
* Check if a key/value pair is an event handler (includes undefined values).
*/
function isEventHandler(key: string, value: unknown): boolean {
return isEventHandlerKey(key) && (typeof value === 'function' || typeof value === 'undefined');
}
/**
* Merge two event handlers - external runs first, ours runs second.
*/
function mergeEventHandlers(
ours: ((event: SyntheticEvent) => void) | undefined,
theirs: ((event: SyntheticEvent) => void) | undefined
): ((event: SyntheticEvent) => void) | undefined {
if (!theirs) return ours;
if (!ours) return theirs;
return (event: SyntheticEvent) => {
theirs(event);
ours(event);
};
}
/**
* Merge two className values - concatenate strings.
*/
function mergeClassNames(ours: string | undefined, theirs: string | undefined): string | undefined {
if (theirs && ours) return `${theirs} ${ours}`;
return theirs || ours;
}
/**
* Merge two style objects - theirs overwrites conflicts.
*/
function mergeStyles(ours: CSSProperties | undefined, theirs: CSSProperties | undefined): CSSProperties | undefined {
if (!theirs) return ours;
if (!ours) return theirs;
return { ...ours, ...theirs };
}
/**
* Merge a single props object into accumulated result.
*/
function mergeOne<T extends ElementType>(
merged: Record<string, unknown>,
props: Props<T> | undefined
): Record<string, unknown> {
if (!props) return merged;
for (const key in props) {
const value = props[key as keyof typeof props];
if (key === 'className') {
merged.className = mergeClassNames(merged.className as string | undefined, value as string);
} else if (key === 'style') {
merged.style = mergeStyles(merged.style as CSSProperties | undefined, value as CSSProperties);
} else if (isEventHandler(key, value)) {
merged[key] = mergeEventHandlers(
merged[key] as ((event: SyntheticEvent) => void) | undefined,
value as (event: SyntheticEvent) => void
);
} else {
merged[key] = value;
}
}
return merged;
}
/**
* Merge multiple props objects.
*
* - Event handlers (on*): chained - external first, ours second
* - className: concatenated
* - style: merged objects (external wins conflicts)
* - other: last one wins
*
* @example
* ```ts
* const merged = mergeProps(
* { onClick: ourHandler, className: 'base' },
* { onClick: theirHandler, className: 'custom' }
* );
* // { onClick: chainedHandler, className: 'custom base' }
* ```
*/
export function mergeProps<T extends ElementType>(...propSets: (Props<T> | undefined)[]): Props<T> {
let merged: Record<string, unknown> = {};
for (const props of propSets) {
merged = mergeOne(merged, props);
}
return merged as Props<T>;
}
@@ -0,0 +1,256 @@
import type { MouseEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { mergeProps } from '../merge-props';
// Create a minimal mock event for testing
function createMockEvent(): MouseEvent<HTMLButtonElement> {
return { type: 'click' } as MouseEvent<HTMLButtonElement>;
}
describe('mergeProps', () => {
describe('event handlers', () => {
it('merges two event handlers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const merged = mergeProps<'button'>({ onClick: handler1 }, { onClick: handler2 });
merged.onClick?.(createMockEvent());
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('calls handlers in right-to-left order (rightmost first)', () => {
const log: string[] = [];
const merged = mergeProps<'button'>(
{ onClick: () => log.push('1') },
{ onClick: () => log.push('2') },
{ onClick: () => log.push('3') }
);
merged.onClick?.(createMockEvent());
expect(log).toEqual(['3', '2', '1']);
});
it('chains multiple event handlers', () => {
const handlers = [vi.fn(), vi.fn(), vi.fn(), vi.fn()];
const merged = mergeProps<'button'>(
{ onClick: handlers[0] },
{ onClick: handlers[1] },
{ onClick: handlers[2] },
{ onClick: handlers[3] }
);
merged.onClick?.(createMockEvent());
for (const handler of handlers) {
expect(handler).toHaveBeenCalledTimes(1);
}
});
it('skips undefined handlers', () => {
const log: string[] = [];
const merged = mergeProps<'button'>(
{ onClick: () => log.push('1') },
{ onClick: undefined },
{ onClick: () => log.push('3') }
);
merged.onClick?.(createMockEvent());
expect(log).toEqual(['3', '1']);
});
it('handles onKeyDown event handlers', () => {
const log: string[] = [];
const merged = mergeProps<'button'>({ onKeyDown: () => log.push('1') }, { onKeyDown: () => log.push('2') });
merged.onKeyDown?.({} as React.KeyboardEvent<HTMLButtonElement>);
expect(log).toEqual(['2', '1']);
});
it('returns single handler if only one defined', () => {
const handler = vi.fn();
const merged = mergeProps<'button'>({ onClick: handler }, { title: 'test' });
merged.onClick?.(createMockEvent());
expect(handler).toHaveBeenCalledTimes(1);
});
it('passes event to all handlers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const event = createMockEvent();
const merged = mergeProps<'button'>({ onClick: handler1 }, { onClick: handler2 });
merged.onClick?.(event);
expect(handler1).toHaveBeenCalledWith(event);
expect(handler2).toHaveBeenCalledWith(event);
});
});
describe('className', () => {
it('concatenates classNames with rightmost first', () => {
const merged = mergeProps<'div'>({ className: 'base' }, { className: 'custom' });
expect(merged.className).toBe('custom base');
});
it('concatenates multiple classNames', () => {
const merged = mergeProps<'div'>({ className: 'a' }, { className: 'b' }, { className: 'c' });
expect(merged.className).toBe('c b a');
});
it('returns single className if only one defined', () => {
const merged = mergeProps<'div'>({ className: 'only' }, { id: 'test' });
expect(merged.className).toBe('only');
});
it('returns undefined if no classNames defined', () => {
const merged = mergeProps<'div'>({ id: 'test' }, { title: 'hello' });
expect(merged.className).toBeUndefined();
});
it('handles undefined className in middle', () => {
const merged = mergeProps<'div'>({ className: 'a' }, { className: undefined }, { className: 'c' });
expect(merged.className).toBe('c a');
});
});
describe('style', () => {
it('merges style objects with rightmost winning conflicts', () => {
const merged = mergeProps<'div'>(
{ style: { color: 'blue', backgroundColor: 'blue' } },
{ style: { color: 'red' } }
);
expect(merged.style).toEqual({
color: 'red',
backgroundColor: 'blue',
});
});
it('merges multiple style objects', () => {
const merged = mergeProps<'div'>(
{ style: { color: 'blue' } },
{ style: { backgroundColor: 'green' } },
{ style: { color: 'red', border: '1px solid' } }
);
expect(merged.style).toEqual({
color: 'red',
backgroundColor: 'green',
border: '1px solid',
});
});
it('returns single style if only one defined', () => {
const style = { color: 'red' };
const merged = mergeProps<'div'>({ style }, { id: 'test' });
expect(merged.style).toEqual(style);
});
it('returns undefined if no styles defined', () => {
const merged = mergeProps<'div'>({ id: 'test' }, { title: 'hello' });
expect(merged.style).toBeUndefined();
});
it('handles undefined style in middle', () => {
const merged = mergeProps<'div'>(
{ style: { color: 'blue' } },
{ style: undefined },
{ style: { backgroundColor: 'red' } }
);
expect(merged.style).toEqual({
color: 'blue',
backgroundColor: 'red',
});
});
});
describe('regular props', () => {
it('overwrites with rightmost value (last wins)', () => {
const merged = mergeProps<'button'>({ title: 'first' }, { title: 'second' }, { title: 'third' });
expect(merged.title).toBe('third');
});
it('preserves non-conflicting props from all sources', () => {
const merged = mergeProps<'button'>({ id: 'my-id' }, { role: 'button' }, { 'aria-label': 'Click me' });
expect(merged.id).toBe('my-id');
expect(merged.role).toBe('button');
expect(merged['aria-label']).toBe('Click me');
});
it('handles boolean props', () => {
const merged = mergeProps<'button'>({ disabled: true }, { disabled: false });
expect(merged.disabled).toBe(false);
});
it('handles aria attributes', () => {
const merged = mergeProps<'button'>({ 'aria-pressed': true }, { 'aria-disabled': false });
expect(merged['aria-pressed']).toBe(true);
expect(merged['aria-disabled']).toBe(false);
});
});
describe('edge cases', () => {
it('handles undefined prop sets', () => {
const handler = vi.fn();
const merged = mergeProps<'button'>({ onClick: handler }, undefined, { title: 'test' });
expect(merged.onClick).toBe(handler);
expect(merged.title).toBe('test');
});
it('handles empty prop sets', () => {
const handler = vi.fn();
const merged = mergeProps<'button'>({}, { onClick: handler }, {});
expect(merged.onClick).toBe(handler);
});
it('returns empty object for no arguments', () => {
const merged = mergeProps<'button'>();
expect(merged).toEqual({});
});
it('returns empty object for all undefined arguments', () => {
const merged = mergeProps<'button'>(undefined, undefined);
expect(merged).toEqual({});
});
it('does not merge ref (just overwrites)', () => {
const ref1 = { current: null };
const ref2 = { current: null };
const merged = mergeProps<'button'>({ ref: ref1 }, { ref: ref2 });
expect(merged.ref).toBe(ref2);
});
});
});
@@ -0,0 +1,597 @@
import { cleanup, render } from '@testing-library/react';
import type { ForwardedRef, Ref } from 'react';
import { createRef, forwardRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderElement } from '../use-render';
afterEach(cleanup);
interface TestState {
active?: boolean;
}
interface TestComponentProps extends renderElement.ComponentProps<TestState> {
active?: boolean;
id?: string;
title?: string;
onClick?: () => void;
'data-testid'?: string;
}
const TestComponent = forwardRef(function TestComponent(props: TestComponentProps, ref: ForwardedRef<HTMLDivElement>) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ 'data-component': 'test' }, elementProps],
}
);
});
describe('renderElement', () => {
describe('default tag rendering', () => {
it('renders the specified element tag', () => {
const { container } = render(<TestComponent />);
const element = container.firstElementChild;
expect(element?.tagName).toBe('DIV');
});
it('spreads props onto the element', () => {
const { container } = render(<TestComponent id="my-id" title="my-title" />);
const element = container.firstElementChild;
expect(element?.getAttribute('id')).toBe('my-id');
expect(element?.getAttribute('title')).toBe('my-title');
});
it('includes internal props', () => {
const { container } = render(<TestComponent />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-component')).toBe('test');
});
});
describe('className', () => {
it('accepts className as string', () => {
const { container } = render(<TestComponent className="my-class" />);
const element = container.firstElementChild;
expect(element?.className).toContain('my-class');
});
it('accepts className as function of state', () => {
const { container } = render(
<TestComponent active className={(state) => (state.active ? 'active' : 'inactive')} />
);
const element = container.firstElementChild;
expect(element?.className).toContain('active');
expect(element?.className).not.toContain('inactive');
});
it('handles className function returning undefined', () => {
const { container } = render(<TestComponent className={(state) => (state.active ? 'active' : undefined)} />);
const element = container.firstElementChild;
expect(element?.className).not.toContain('active');
});
it('merges className with props className', () => {
// Create a component that has internal className in props
const ComponentWithInternalClass = forwardRef(function ComponentWithInternalClass(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ className: 'internal-class' }, elementProps],
}
);
});
const { container } = render(<ComponentWithInternalClass className="external-class" />);
const element = container.firstElementChild;
expect(element?.className).toContain('internal-class');
expect(element?.className).toContain('external-class');
});
});
describe('style', () => {
it('accepts style as object', () => {
const { container } = render(<TestComponent style={{ color: 'red' }} />);
const element = container.firstElementChild as HTMLElement;
expect(element?.style.color).toBe('red');
});
it('accepts style as function of state', () => {
const { container } = render(
<TestComponent active style={(state) => ({ color: state.active ? 'green' : 'red' })} />
);
const element = container.firstElementChild as HTMLElement;
expect(element?.style.color).toBe('green');
});
it('handles style function returning undefined', () => {
const { container } = render(
<TestComponent style={(state) => (state.active ? { color: 'green' } : undefined)} />
);
const element = container.firstElementChild as HTMLElement;
expect(element?.style.color).toBe('');
});
it('merges style with props style', () => {
const ComponentWithInternalStyle = forwardRef(function ComponentWithInternalStyle(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ style: { padding: '10px' } }, elementProps],
}
);
});
const { container } = render(<ComponentWithInternalStyle style={{ color: 'red' }} />);
const element = container.firstElementChild as HTMLElement;
expect(element?.style.padding).toBe('10px');
expect(element?.style.color).toBe('red');
});
});
describe('render prop as function', () => {
it('calls render function with merged props and state', () => {
const renderFn = vi.fn((props, state) => <span {...props} data-active={String(state.active)} />);
const { container } = render(<TestComponent active render={renderFn} data-testid="custom" />);
expect(renderFn).toHaveBeenCalled();
const [receivedProps, receivedState] = renderFn.mock.calls[0]!;
expect(receivedProps['data-testid']).toBe('custom');
expect(receivedProps['data-component']).toBe('test');
expect(receivedState).toEqual({ active: true });
const element = container.firstElementChild;
expect(element?.tagName).toBe('SPAN');
expect(element?.getAttribute('data-active')).toBe('true');
});
it('passes ref to render function props', () => {
const componentRef = createRef<HTMLSpanElement>();
render(<TestComponent ref={componentRef as Ref<HTMLDivElement>} render={(props) => <span {...props} />} />);
expect(componentRef.current).toBeInstanceOf(HTMLSpanElement);
});
it('merges className and style into props', () => {
const renderFn = vi.fn((props) => <span {...props} />);
render(<TestComponent className="my-class" style={{ color: 'red' }} render={renderFn} />);
const [receivedProps] = renderFn.mock.calls[0]!;
expect(receivedProps.className).toContain('my-class');
expect(receivedProps.style).toEqual({ color: 'red' });
});
});
describe('render prop as element', () => {
it('clones element with merged props', () => {
const { container } = render(<TestComponent render={<span />} data-testid="merged" />);
const element = container.firstElementChild;
expect(element?.tagName).toBe('SPAN');
expect(element?.getAttribute('data-testid')).toBe('merged');
expect(element?.getAttribute('data-component')).toBe('test');
});
it('merges className from render element and component', () => {
const { container } = render(
<TestComponent className="component-class" render={<span className="render-class" />} />
);
const element = container.firstElementChild;
expect(element?.className).toContain('component-class');
expect(element?.className).toContain('render-class');
});
it('merges style from render element and component', () => {
const { container } = render(
<TestComponent style={{ color: 'red' }} render={<span style={{ fontSize: '16px' }} />} />
);
const element = container.firstElementChild as HTMLElement;
expect(element?.style.color).toBe('red');
expect(element?.style.fontSize).toBe('16px');
});
it('preserves render element ref', () => {
const CustomElement = forwardRef<HTMLSpanElement, React.ComponentPropsWithRef<'span'>>(
function CustomElement(props, ref) {
return <span ref={ref} {...props} />;
}
);
const renderRef = createRef<HTMLSpanElement>();
const componentRef = createRef<HTMLDivElement>();
render(<TestComponent ref={componentRef} render={<CustomElement ref={renderRef} />} />);
expect(renderRef.current).toBeInstanceOf(HTMLSpanElement);
expect(componentRef.current).toBeInstanceOf(HTMLSpanElement);
expect(renderRef.current).toBe(componentRef.current);
});
});
describe('ref composition', () => {
it('forwards single ref', () => {
const ref = createRef<HTMLDivElement>();
render(<TestComponent ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it('forwards array of refs', () => {
const ref1 = createRef<HTMLDivElement>();
const ref2 = createRef<HTMLDivElement>();
// Component that accepts array of refs
const MultiRefComponent = forwardRef(function MultiRefComponent(
props: TestComponentProps,
_ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref: [ref1, ref2],
props: [elementProps],
}
);
});
render(<MultiRefComponent />);
expect(ref1.current).toBeInstanceOf(HTMLDivElement);
expect(ref2.current).toBeInstanceOf(HTMLDivElement);
expect(ref1.current).toBe(ref2.current);
});
it('handles undefined refs in array', () => {
const ref1 = createRef<HTMLDivElement>();
const MultiRefComponent = forwardRef(function MultiRefComponent(
props: TestComponentProps,
_ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref: [ref1, undefined] as Ref<HTMLDivElement>[],
props: [elementProps],
}
);
});
render(<MultiRefComponent />);
expect(ref1.current).toBeInstanceOf(HTMLDivElement);
});
it('composes ref from render element with forwarded ref', () => {
const CustomElement = forwardRef<HTMLSpanElement, React.ComponentPropsWithRef<'span'>>(
function CustomElement(props, ref) {
return <span ref={ref} {...props} />;
}
);
const elementRef = createRef<HTMLSpanElement>();
const componentRef = createRef<HTMLDivElement>();
render(<TestComponent ref={componentRef} render={<CustomElement ref={elementRef} />} />);
expect(elementRef.current).toBeInstanceOf(HTMLSpanElement);
expect(componentRef.current).toBeInstanceOf(HTMLSpanElement);
expect(elementRef.current).toBe(componentRef.current);
});
});
describe('props merging', () => {
it('merges array of props objects', () => {
const ComponentWithMultipleProps = forwardRef(function ComponentWithMultipleProps(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ 'data-first': 'first' }, { 'data-second': 'second' }, elementProps],
}
);
});
const { container } = render(<ComponentWithMultipleProps data-third="third" />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-first')).toBe('first');
expect(element?.getAttribute('data-second')).toBe('second');
expect(element?.getAttribute('data-third')).toBe('third');
});
it('chains event handlers from props array', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const ComponentWithHandlers = forwardRef(function ComponentWithHandlers(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, onClick, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ onClick: handler1 }, { onClick }, elementProps],
}
);
});
const { container } = render(<ComponentWithHandlers onClick={handler2} />);
const element = container.firstElementChild as HTMLElement;
element.click();
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('last prop wins for non-special props', () => {
const ComponentWithConflictingProps = forwardRef(function ComponentWithConflictingProps(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [{ title: 'first' }, { title: 'second' }, elementProps],
}
);
});
const { container } = render(<ComponentWithConflictingProps title="third" />);
const element = container.firstElementChild;
expect(element?.getAttribute('title')).toBe('third');
});
});
describe('state data attributes', () => {
it('generates data-* attributes from state boolean true values', () => {
const { container } = render(<TestComponent active />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-active')).toBe('');
});
it('does not generate data-* attributes from state boolean false values', () => {
const { container } = render(<TestComponent active={false} />);
const element = container.firstElementChild;
expect(element?.hasAttribute('data-active')).toBe(false);
});
it('generates data-* attributes from state with multiple properties', () => {
interface MultiState {
paused: boolean;
ended: boolean;
volume: number;
}
const MultiStateComponent = forwardRef(function MultiStateComponent(
props: { paused?: boolean; ended?: boolean; volume?: number } & renderElement.ComponentProps<MultiState>,
ref: ForwardedRef<HTMLDivElement>
) {
const {
className,
style,
render: renderProp,
paused = false,
ended = false,
volume = 1,
...elementProps
} = props;
const state: MultiState = { paused, ended, volume };
return renderElement('div', { className, style, render: renderProp }, { state, ref, props: [elementProps] });
});
const { container } = render(<MultiStateComponent paused ended={false} volume={0.5} />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-paused')).toBe('');
expect(element?.hasAttribute('data-ended')).toBe(false);
expect(element?.getAttribute('data-volume')).toBe('0.5');
});
it('converts state keys to lowercase for data attributes', () => {
interface CamelCaseState {
isPaused: boolean;
}
const CamelCaseComponent = forwardRef(function CamelCaseComponent(
props: { isPaused?: boolean } & renderElement.ComponentProps<CamelCaseState>,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, isPaused = false, ...elementProps } = props;
const state: CamelCaseState = { isPaused };
return renderElement('div', { className, style, render: renderProp }, { state, ref, props: [elementProps] });
});
const { container } = render(<CamelCaseComponent isPaused />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-ispaused')).toBe('');
});
it('state data-* attributes can be overridden by explicit props', () => {
const ComponentWithExplicitDataAttr = forwardRef(function ComponentWithExplicitDataAttr(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
// Explicit prop comes after state in merge order, so it wins
props: [elementProps],
}
);
});
// State would generate data-active="", but explicit prop overrides
const { container } = render(<ComponentWithExplicitDataAttr active data-active="custom" />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-active')).toBe('custom');
});
});
describe('edge cases', () => {
it('handles empty props array', () => {
const ComponentWithEmptyProps = forwardRef(function ComponentWithEmptyProps(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: [],
}
);
});
const { container } = render(<ComponentWithEmptyProps className="test" />);
const element = container.firstElementChild;
expect(element?.tagName).toBe('DIV');
expect(element?.className).toContain('test');
});
it('handles undefined props', () => {
const ComponentWithUndefinedProps = forwardRef(function ComponentWithUndefinedProps(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: undefined,
}
);
});
const { container } = render(<ComponentWithUndefinedProps className="test" />);
const element = container.firstElementChild;
expect(element?.tagName).toBe('DIV');
expect(element?.className).toContain('test');
});
it('handles single props object (not array)', () => {
const ComponentWithSingleProps = forwardRef(function ComponentWithSingleProps(
props: TestComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, active = false, ...elementProps } = props;
const state: TestState = { active };
return renderElement(
'div',
{ className, style, render: renderProp },
{
state,
ref,
props: { 'data-single': 'single', ...elementProps },
}
);
});
const { container } = render(<ComponentWithSingleProps data-testid="test" />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-single')).toBe('single');
expect(element?.getAttribute('data-testid')).toBe('test');
});
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactElement } from 'react';
/** Props that can be spread on any HTML element. */
export type HTMLProps<T = any> = React.HTMLAttributes<T> & {
ref?: React.Ref<T> | undefined;
};
/** Render function signature - receives props and state, returns element. */
export type RenderFunction<Props, State> = (props: Props, state: State) => ReactElement;
/** Render prop - either a React element or a render function. */
export type RenderProp<State> = ReactElement | RenderFunction<HTMLProps, State>;
/**
* Standard props for UI components.
*
* Provides consistent API across all UI components:
* - `className` as string or function of state
* - `style` as object or function of state
* - `render` prop for element customization
*/
export type UIComponentProps<TagName extends keyof React.JSX.IntrinsicElements, State> = Omit<
React.JSX.IntrinsicElements[TagName],
'className' | 'style'
> & {
/** Class name or function returning class name from state. */
className?: string | ((state: State) => string | undefined) | undefined;
/** Style or function returning style from state. */
style?: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined;
/** Render prop for custom element. */
render?: RenderProp<State> | undefined;
};
/** Extract props type from an element type. */
export type PropsOf<T extends ElementType> = ComponentPropsWithRef<T>;
+11 -8
View File
@@ -1,10 +1,11 @@
'use client';
import { isFunction } from '@videojs/utils/predicate';
import type { Ref, RefCallback } from 'react';
import { useCallback } from 'react';
type PossibleRef<T> = Ref<T> | undefined;
type OptionalRef<T> = Ref<T> | undefined;
/**
* Set a given ref to a given value.
@@ -13,8 +14,8 @@ type PossibleRef<T> = Ref<T> | undefined;
*
* @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') {
function setRef<T>(ref: OptionalRef<T>, value: T): (() => void) | void | undefined {
if (isFunction(ref)) {
return ref(value);
} else if (ref !== null && ref !== undefined) {
ref.current = value;
@@ -30,17 +31,19 @@ function setRef<T>(ref: PossibleRef<T>, value: T): (() => void) | void | undefin
* return <div ref={composedRef} />;
* ```
*/
export function composeRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T> {
export function composeRefs<T>(...refs: (OptionalRef<T> | OptionalRef<T>[])[]): RefCallback<T> {
const flatRefs = refs.flat();
return (node): (() => void) | void => {
const cleanups = refs.map((ref) => setRef(ref, node));
const cleanups = flatRefs.map((ref) => setRef(ref, node));
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === 'function') {
if (isFunction(cleanup)) {
cleanup();
} else {
setRef(refs[i], null);
setRef(flatRefs[i], null);
}
}
};
@@ -58,6 +61,6 @@ export function composeRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T> {
* return <div ref={composedRef} />;
* ```
*/
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T> {
export function useComposedRefs<T>(...refs: OptionalRef<T>[]): RefCallback<T> {
return useCallback(composeRefs(...refs), [...refs]);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { getStateDataAttrs } from '@videojs/core/dom';
import { isFunction } from '@videojs/utils/predicate';
import type { CSSProperties, ReactElement, Ref } from 'react';
import { cloneElement, createElement, isValidElement } from 'react';
import { mergeProps } from './merge-props';
import type { HTMLProps, RenderProp } from './types';
import { composeRefs } from './use-composed-refs';
type IntrinsicTagName = keyof React.JSX.IntrinsicElements;
export interface UseRenderComponentProps<State> {
className?: string | ((state: State) => string | undefined) | undefined;
style?: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined;
render?: RenderProp<State> | undefined;
}
export interface UseRenderParameters<State, RenderedElementType extends Element> {
state: State;
ref?: Ref<RenderedElementType> | Ref<RenderedElementType>[] | undefined;
props?: object | object[] | undefined;
}
function resolveClassName<State>(
className: string | ((state: State) => string | undefined) | undefined,
state: State
): string | undefined {
return isFunction(className) ? className(state) : className;
}
function resolveStyle<State>(
style: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined,
state: State
): CSSProperties | undefined {
return isFunction(style) ? style(state) : style;
}
function getElementRef(element: ReactElement): Ref<unknown> | undefined {
// React 19+ uses element.props.ref, older versions use element.ref
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const elementAny = element as any;
return elementAny.ref ?? elementAny.props?.ref;
}
/**
* Render a UI component element.
*
* Handles:
* - Default tag rendering
* - Render prop (element or function)
* - Props merging (event handlers chained, className concatenated, style merged)
* - Ref composition
* - className/style as functions of state
*
* @example
* ```tsx
* return renderElement('button', componentProps, {
* state,
* ref: [forwardedRef, buttonRef],
* props: [{ type: 'button' }, elementProps, getButtonProps],
* });
* ```
*/
export function renderElement<
State extends object,
RenderedElementType extends Element,
TagName extends IntrinsicTagName,
>(
element: TagName,
componentProps: UseRenderComponentProps<State>,
params: UseRenderParameters<State, RenderedElementType>
): ReactElement {
const { className: classNameProp, style: styleProp, render } = componentProps;
const { state, ref, props } = params;
// Resolve className and style if they're functions
const className = resolveClassName(classNameProp, state);
const style = resolveStyle(styleProp, state);
// Generate data attributes from state
const stateDataAttrs = getStateDataAttrs(state);
// Merge: state data attrs first, then props (so props can override)
const propsArray = Array.isArray(props) ? props : props ? [props] : [];
const mergedProps = mergeProps(stateDataAttrs, ...(propsArray as Record<string, unknown>[]));
if (className !== undefined) {
// Add resolved className and style
mergedProps.className = mergedProps.className ? `${mergedProps.className} ${className}` : className;
}
if (style !== undefined) {
mergedProps.style = mergedProps.style ? { ...(mergedProps.style as CSSProperties), ...style } : style;
}
if (isFunction(render)) {
// Render function: call with props and state
const mergedRef = composeRefs(ref, mergedProps.ref);
return render({ ...mergedProps, ref: mergedRef } as HTMLProps, state);
}
if (isValidElement(render)) {
const elementRef = getElementRef(render);
const mergedRef = composeRefs(ref, mergedProps.ref, elementRef);
const elementProps = mergeProps(mergedProps, render.props as Record<string, unknown>);
elementProps.ref = mergedRef;
return cloneElement(render, elementProps);
}
// Default tag
const mergedRef = composeRefs(ref, mergedProps.ref);
mergedProps.ref = mergedRef;
return createElement(element, mergedProps);
}
export namespace renderElement {
export type ComponentProps<State> = UseRenderComponentProps<State>;
export type Parameters<State, RenderedElementType extends Element> = UseRenderParameters<State, RenderedElementType>;
}