Skip to content

Commit

Permalink
[core] Create mergeReactProps utility (#243)
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks authored Apr 2, 2024
1 parent a2bde07 commit 087276d
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 56 deletions.
8 changes: 4 additions & 4 deletions docs/pages/base-ui/api/use-switch.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@
"checked": { "type": { "name": "boolean", "description": "boolean" }, "required": true },
"getButtonProps": {
"type": {
"name": "(externalProps?: React.HTMLAttributes<HTMLButtonElement>) => UseSwitchButtonElementProps",
"description": "(externalProps?: React.HTMLAttributes<HTMLButtonElement>) => UseSwitchButtonElementProps"
"name": "(externalProps?: React.ComponentPropsWithRef<'button'>) => React.ComponentPropsWithRef<'button'>",
"description": "(externalProps?: React.ComponentPropsWithRef<'button'>) => React.ComponentPropsWithRef<'button'>"
},
"required": true
},
"getInputProps": {
"type": {
"name": "(externalProps?: React.HTMLAttributes<HTMLInputElement>) => UseSwitchInputElementProps",
"description": "(externalProps?: React.HTMLAttributes<HTMLInputElement>) => UseSwitchInputElementProps"
"name": "(externalProps?: React.ComponentPropsWithRef<'input'>) => React.ComponentPropsWithRef<'input'>",
"description": "(externalProps?: React.ComponentPropsWithRef<'input'>) => React.ComponentPropsWithRef<'input'>"
},
"required": true
}
Expand Down
8 changes: 4 additions & 4 deletions packages/mui-base/src/Switch/Switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import refType from '@mui/utils/refType';
import { useSwitch } from '../useSwitch';
import { SwitchProps, SwitchOwnerState } from './Switch.types';
import type { SwitchProps, SwitchOwnerState } from './Switch.types';
import { resolveClassName } from '../utils/resolveClassName';
import { SwitchContext } from './SwitchContext';
import { useSwitchStyleHooks } from './useSwitchStyleHooks';

function defaultRender(props: React.ComponentPropsWithRef<'button'>) {
// eslint-disable-next-line react/button-has-type
return <button {...props} />;
return <button type="button" {...props} />;
}

/**
Expand All @@ -37,9 +36,10 @@ const Switch = React.forwardRef(function Switch(
onChange,
readOnly = false,
required = false,
render = defaultRender,
render: renderProp,
...other
} = props;
const render = renderProp ?? defaultRender;

const { getInputProps, getButtonProps, checked } = useSwitch(props);

Expand Down
6 changes: 3 additions & 3 deletions packages/mui-base/src/Switch/Switch.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BaseUiComponentCommonProps } from '../utils/BaseUiComponentCommonProps';
import type { BaseUIComponentProps } from '../utils/BaseUI.types';
import { UseSwitchParameters } from '../useSwitch';

export type SwitchOwnerState = {
Expand All @@ -10,6 +10,6 @@ export type SwitchOwnerState = {

export interface SwitchProps
extends UseSwitchParameters,
Omit<BaseUiComponentCommonProps<'button', SwitchOwnerState>, 'onChange'> {}
Omit<BaseUIComponentProps<'button', SwitchOwnerState>, 'onChange'> {}

export interface SwitchThumbProps extends BaseUiComponentCommonProps<'span', SwitchOwnerState> {}
export interface SwitchThumbProps extends BaseUIComponentProps<'span', SwitchOwnerState> {}
75 changes: 37 additions & 38 deletions packages/mui-base/src/useSwitch/useSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useControlled } from '../utils/useControlled';
import { UseSwitchParameters, UseSwitchReturnValue } from './useSwitch.types';
import { useForkRef } from '../utils/useForkRef';
import { visuallyHidden } from '../utils/visuallyHidden';
import { mergeReactProps } from '../utils/mergeReactProps';

/**
* The basic building block for creating custom switches.
Expand Down Expand Up @@ -38,49 +39,47 @@ export function useSwitch(params: UseSwitchParameters): UseSwitchReturnValue {
state: 'checked',
});

const getButtonProps: UseSwitchReturnValue['getButtonProps'] = React.useCallback(
(otherProps = {}) => ({
type: 'button',
role: 'switch',
'aria-checked': checked,
'aria-disabled': disabled,
'aria-readonly': readOnly,
...otherProps,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
otherProps.onClick?.(event);
if (event.defaultPrevented || readOnly) {
return;
}
const getButtonProps = React.useCallback(
(otherProps = {}) =>
mergeReactProps<'button'>(otherProps, {
type: 'button',
role: 'switch',
'aria-checked': checked,
'aria-disabled': disabled,
'aria-readonly': readOnly,
onClick(event) {
if (event.defaultPrevented || readOnly) {
return;
}

inputRef.current?.click();
},
}),
inputRef.current?.click();
},
}),
[checked, disabled, readOnly],
);

const getInputProps: UseSwitchReturnValue['getInputProps'] = React.useCallback(
(otherProps = {}) => ({
checked,
disabled,
name,
required,
style: visuallyHidden,
tabIndex: -1,
type: 'checkbox',
'aria-hidden': true,
...otherProps,
ref: handleInputRef,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
return;
}
const getInputProps = React.useCallback(
(otherProps = {}) =>
mergeReactProps<'input'>(otherProps, {
checked,
disabled,
name,
required,
style: visuallyHidden,
tabIndex: -1,
type: 'checkbox',
'aria-hidden': true,
ref: handleInputRef,
onChange(event) {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
return;
}

setCheckedState(event.target.checked);
onChange?.(event);
otherProps.onChange?.(event);
},
}),
setCheckedState(event.target.checked);
onChange?.(event);
},
}),
[checked, disabled, name, required, handleInputRef, onChange, setCheckedState],
);

Expand Down
8 changes: 4 additions & 4 deletions packages/mui-base/src/useSwitch/useSwitch.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ export interface UseSwitchReturnValue {
* @returns Props that should be spread on the input element
*/
getInputProps: (
externalProps?: React.HTMLAttributes<HTMLInputElement>,
) => UseSwitchInputElementProps;
externalProps?: React.ComponentPropsWithRef<'input'>,
) => React.ComponentPropsWithRef<'input'>;
/**
* Resolver for the button element's props.
* @param externalProps Additional props for the input element
* @returns Props that should be spread on the button element
*/
getButtonProps: (
externalProps?: React.HTMLAttributes<HTMLButtonElement>,
) => UseSwitchButtonElementProps;
externalProps?: React.ComponentPropsWithRef<'button'>,
) => React.ComponentPropsWithRef<'button'>;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
export type BaseUIEvent<E extends React.SyntheticEvent<Element, Event>> = E & {
preventBaseUIHandler: () => void;
};

type WithPreventBaseUIHandler<T> = T extends (event: infer E) => any
? E extends React.SyntheticEvent<Element, Event>
? (event: BaseUIEvent<E>) => ReturnType<T>
: never
: T extends undefined
? undefined
: T;

/**
* Adds a `preventBaseUIHandler` method to all event handlers.
*/
export type WithBaseUIEvent<T> = {
[K in keyof T]: WithPreventBaseUIHandler<T[K]>;
};

/**
* Shape of the render prop: a function that takes props to be spread on the element and component's state and returns a React element.
*
Expand All @@ -9,10 +28,9 @@ export type ComponentRenderFn<Props, State> = (props: Props, state: State) => Re
/**
* Props shared by all Base UI components.
* Contains `className` (string or callback taking the component's state as an argument) and `render` (function to customize rendering).
*/
export type BaseUiComponentCommonProps<ElementType extends React.ElementType, OwnerState> = Omit<
React.ComponentPropsWithoutRef<ElementType>,
export type BaseUIComponentProps<ElementType extends React.ElementType, OwnerState> = Omit<
WithBaseUIEvent<React.ComponentPropsWithoutRef<ElementType>>,
'className'
> & {
/**
Expand Down
108 changes: 108 additions & 0 deletions packages/mui-base/src/utils/mergeReactProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import { mergeReactProps } from './mergeReactProps';

describe('mergeReactProps', () => {
it('merges event handlers', () => {
const theirProps = {
onClick: spy(),
onKeyDown: spy(),
};
const ourProps = {
onClick: spy(),
onPaste: spy(),
};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

mergedProps.onClick?.({} as any);
mergedProps.onKeyDown?.({} as any);
mergedProps.onPaste?.({} as any);

expect(theirProps.onClick.calledBefore(ourProps.onClick)).to.equal(true);
expect(theirProps.onClick.callCount).to.equal(1);
expect(ourProps.onClick.callCount).to.equal(1);
expect(theirProps.onKeyDown.callCount).to.equal(1);
expect(ourProps.onPaste.callCount).to.equal(1);
});

it('merges styles', () => {
const theirProps = {
style: { color: 'red' },
};
const ourProps = {
style: { color: 'blue', backgroundColor: 'blue' },
};
const mergedProps = mergeReactProps<'div'>(theirProps, ourProps);

expect(mergedProps.style).to.deep.equal({
color: 'red',
backgroundColor: 'blue',
});
});

it('merges styles with undefined', () => {
const theirProps = {
style: { color: 'red' },
};
const ourProps = {
style: undefined,
};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

expect(mergedProps.style).to.deep.equal({
color: 'red',
});
});

it('does not merge styles if both are undefined', () => {
const theirProps = {
style: undefined,
};
const ourProps = {
style: undefined,
};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

expect(mergedProps.style).to.equal(undefined);
});

it('does not prevent internal handler if event.preventBaseUIHandler() is not called', () => {
let ran = false;

const mergedProps = mergeReactProps<'button'>(
{
onClick() {},
},
{
onClick() {
ran = true;
},
},
);

mergedProps.onClick?.({} as any);

expect(ran).to.equal(true);
});

it('prevents internal handler if event.preventBaseUIHandler() is called', () => {
let ran = false;

const mergedProps = mergeReactProps<'button'>(
{
onClick(event) {
event.preventBaseUIHandler();
},
},
{
onClick() {
ran = true;
},
},
);

mergedProps.onClick?.({} as any);

expect(ran).to.equal(false);
});
});
53 changes: 53 additions & 0 deletions packages/mui-base/src/utils/mergeReactProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type * as React from 'react';
import type { BaseUIEvent, WithBaseUIEvent } from './BaseUI.types';

/**
* Merges two sets of React props such that their event handlers are called in sequence (the user's
* before our internal ones), and allows the user to prevent the internal event handlers from being
* executed by attaching a `preventBaseUIHandler` method. It also merges the `style` prop, whereby
* the user's styles overwrite the internal ones.
* @important **`className` and `ref` are not merged.**
* @param externalProps the user's external props.
* @param internalProps our own internal props.
* @returns the merged props.
*/
export function mergeReactProps<T extends React.ElementType>(
externalProps: WithBaseUIEvent<React.ComponentPropsWithRef<T>>,
internalProps: React.ComponentPropsWithRef<T>,
): WithBaseUIEvent<React.ComponentPropsWithRef<T>> {
return Object.entries(externalProps).reduce(
(acc, [key, value]) => {
if (/^on[A-Z]/.test(key) && typeof value === 'function') {
acc[key] = (event: React.SyntheticEvent) => {
let isPrevented = false;

const theirHandler = value;
const ourHandler = internalProps[key];

const baseUIEvent = event as BaseUIEvent<typeof event>;

baseUIEvent.preventBaseUIHandler = () => {
isPrevented = true;
};

const result = theirHandler(baseUIEvent);

if (!isPrevented) {
ourHandler?.(baseUIEvent);
}

return result;
};
} else if (key === 'style') {
if (value || internalProps.style) {
acc[key] = { ...internalProps.style, ...(value || {}) };
}
} else {
acc[key] = value;
}

return acc;
},
{ ...internalProps },
);
}

0 comments on commit 087276d

Please sign in to comment.