diff --git a/packages/react/src/utils/__tests__/mergeProps.test.ts b/packages/react/src/utils/__tests__/mergeProps.test.ts new file mode 100644 index 00000000000..32dd043a326 --- /dev/null +++ b/packages/react/src/utils/__tests__/mergeProps.test.ts @@ -0,0 +1,84 @@ +import {test, expect, vi} from 'vitest' +import {mergeProps} from '../mergeProps' + +test('merges className', () => { + const propsA = {className: 'foo'} + const propsB = {className: 'bar'} + const merged = mergeProps(propsA, propsB) + + expect(merged.className).toBe('foo bar') +}) + +test('merges event handlers', () => { + const onClickA = vi.fn() + const onClickB = vi.fn() + const propsA = {onClick: onClickA} + const propsB = {onClick: onClickB} + const merged = mergeProps(propsA, propsB) + merged.onClick(new MouseEvent('click')) + + expect(onClickA).toHaveBeenCalled() + expect(onClickB).toHaveBeenCalled() +}) + +test('does not call second event handler if first prevents default', () => { + const onClickA = vi.fn(event => event.preventDefault()) + const onClickB = vi.fn() + const propsA = {onClick: onClickA} + const propsB = {onClick: onClickB} + const merged = mergeProps(propsA, propsB) + merged.onClick(new MouseEvent('click', {cancelable: true})) + + expect(onClickA).toHaveBeenCalled() + expect(onClickB).not.toHaveBeenCalled() +}) + +test('overrides event handler props when the existing value is not a function', () => { + const onClick = vi.fn() + const propsA = {onClick: undefined} + const propsB = {onClick} + const merged = mergeProps(propsA, propsB) + + merged.onClick(new MouseEvent('click')) + + expect(onClick).toHaveBeenCalled() +}) + +test('merges style', () => { + const propsA = {style: {color: 'red'}} + const propsB = {style: {backgroundColor: 'blue'}} + const merged = mergeProps(propsA, propsB) + + expect(merged.style).toEqual({color: 'red', backgroundColor: 'blue'}) +}) + +test('overrides non-mergeable props', () => { + const propsA = {id: 'foo'} + const propsB = {id: 'bar'} + const merged = mergeProps(propsA, propsB) + + expect(merged.id).toBe('bar') +}) + +test('adds new props', () => { + const propsA = {foo: 'foo'} + const propsB = {bar: 'bar'} + const merged = mergeProps(propsA, propsB) + + expect(merged.foo).toBe('foo') + expect(merged.bar).toBe('bar') +}) + +test('merges multiple props', () => { + const onClickA = vi.fn() + const onClickB = vi.fn() + const propsA = {className: 'foo', onClick: onClickA, style: {color: 'red'}} + const propsB = {className: 'bar', onClick: onClickB, style: {backgroundColor: 'blue'}} + const merged = mergeProps(propsA, propsB) + merged.onClick(new MouseEvent('click')) + + expect(merged.className).toBe('foo bar') + expect(merged.style).toEqual({color: 'red', backgroundColor: 'blue'}) + expect(onClickA).toHaveBeenCalled() + expect(onClickB).toHaveBeenCalled() +}) diff --git a/packages/react/src/utils/__tests__/mergeProps.types.test.ts b/packages/react/src/utils/__tests__/mergeProps.types.test.ts new file mode 100644 index 00000000000..e149e672c0b --- /dev/null +++ b/packages/react/src/utils/__tests__/mergeProps.types.test.ts @@ -0,0 +1,22 @@ +import {mergeProps} from '../mergeProps' + +export function mergePropsKeepsPropsFromBothObjects() { + const merged = mergeProps({foo: 'foo'}, {bar: 1}) + + return merged satisfies {foo: string; bar: number} +} + +export function mergePropsUsesSecondObjectForOverlappingProps() { + const merged = mergeProps({value: 'one'}, {value: 1}) + + return merged satisfies {value: number} +} + +export function mergePropsDoesNotIntersectOverlappingProps() { + const merged = mergeProps({value: 'one'}, {value: 1}) + + // @ts-expect-error overlapping props should use the second object type instead of an impossible intersection + const value: never = merged.value + + return value +} diff --git a/packages/react/src/utils/mergeProps.ts b/packages/react/src/utils/mergeProps.ts new file mode 100644 index 00000000000..bd50822d07f --- /dev/null +++ b/packages/react/src/utils/mergeProps.ts @@ -0,0 +1,60 @@ +import {clsx} from 'clsx' +import type {Merge} from './types' + +type EventHandler = (event: {defaultPrevented?: boolean}) => void +type ClassValue = Parameters[number] + +function mergeProps(a: A, b: B): Merge { + const merged = {...a} as Record + + for (const [key, value] of Object.entries(b)) { + if (key in merged) { + const existing = merged[key] + + if (key === 'className') { + merged[key] = clsx(existing as ClassValue, value as ClassValue) + } else if (isEventHandlerKey(key) && isEventHandler(existing) && isEventHandler(value)) { + merged[key] = composeEventHandlers(existing, value) + } else if (key === 'style') { + merged[key] = mergeStyle(existing, value) + } else { + merged[key] = value + } + } else { + merged[key] = value + } + } + + return merged as Merge +} + +function composeEventHandlers(a: EventHandler, b: EventHandler) { + return (event: Parameters[0]) => { + a(event) + + if (!event.defaultPrevented) { + b(event) + } + } +} + +function isEventHandlerKey(key: string) { + return key.startsWith('on') +} + +function isEventHandler(value: unknown): value is EventHandler { + return typeof value === 'function' +} + +function mergeStyle(a: unknown, b: unknown) { + return { + ...(isObject(a) ? a : {}), + ...(isObject(b) ? b : {}), + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export {mergeProps}