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
@@ -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');
});
});
});