mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add play button component (#383)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user