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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user