react-spectrum
react-spectrum copied to clipboard
`MergeProvider`
Provide a general summary of the feature here
It is desirable to be able to merge slot props onto an existing slot. In order to do so, you must extract the context and merge your props into the existing slot and then provide the updated context.
๐ค Expected Behavior?
MergeProvider would consume the context of any context that it is provided and attempt to merge the new values in on top using the mergeProps util
๐ฏ Current Behavior
Only Provider exists and it doesn't support merging, only overriding
๐ Possible Solution
I've created a rough first draft. Keep in mind that I'm using my own mergeProps that is built on top of the RAC mergeProps but replaces the behavior for merging of className and style to support render props.
import { type Context, type ReactNode } from 'react';
import { mergeProps } from '../../utils';
import { type MergeProviderProps } from './types';
function merge<T>(context: Context<T>, next: T, children: ReactNode) {
return function Consumer(prev: T) {
let merged = next;
if (
prev != null &&
next != null &&
typeof prev === 'object' &&
typeof next === 'object'
) {
const prevSlots =
'slots' in prev && (prev.slots as Record<string, object>);
const nextSlots =
'slots' in next && (next.slots as Record<string, object>);
if (prevSlots && nextSlots) {
merged = {
...prev,
...next,
slots: {
...prevSlots,
...nextSlots,
...Object.entries(nextSlots).reduce<Record<string, object>>(
(acc, [key, value]) => {
if (Object.hasOwn(prevSlots, key)) {
acc[key] = mergeProps(prevSlots[key], value);
}
return acc;
},
{}
),
},
} as T;
} else if (!prevSlots && !nextSlots) {
merged = mergeProps(prev as object, next as object) as T;
}
}
return <context.Provider value={merged}>{children}</context.Provider>;
};
}
export function MergeProvider<A, B, C, D, E, F, G, H, I, J, K>({
values,
children,
}: MergeProviderProps<A, B, C, D, E, F, G, H, I, J, K>) {
for (let [context, next] of values) {
children = (
<context.Consumer>
{merge(context as Context<typeof next>, next, children)}
</context.Consumer>
);
}
return <>{children}</>;
}
๐ฆ Context
I'm implementing a custom component built on top of an RAC that currently implements the "remove" slot for a button. I want to maintain all of the RAC props for that slot, but I also want to add my own. If I were to just implement the Provider, then the context is overridden and I would lose (or have to completely reimplement) the RAC props. Instead, I'd like to implement MergeProvider that will automatically merge props for slots that exist in both versions of the context
๐ป Examples
No response
๐งข Your Company/Team
No response
๐ท Tracking Issue
No response
Mind expanding a bit more on the exact setup you are using this MergeProvider for? I think it is fine to roll your own manual merge logic by pulling from the Provider above and then applying the merged props to another Provider below. We can definitely consider creating and exposing one from our end if there is popular demand for it.
What I've got going on is my own custom implementation of Tag(Group), which utilizes the RAC Tag[Group,List]. The RAC Tag supports a slot of "remove" on the ButtonContext to enable the optional functionality of allowing a Tag to be removable. My custom Tag, wraps the RAC Tag, and wraps the MergeProvider around the RAC Tag children. This allows me to provide slot props to the context that get merged with the RAC ButtonContext slot props.
Simplified implementation example:
import { Button, ButtonContext, Tag } from 'react-aria-components';
const MyTag = ({ children, ...rest }) => {
const values = [
[ButtonContext, {
slots: {
remove: {
className: 'my-custom-tag--remove',
// any other Button props I want to merge in
}
}
}]
];
return (
<Tag {...rest} className="my-custom-tag">
{(renderProps) => (
<MergeProvider values={values}>
<div className="my-custom-tag-inner">
{typeof children === 'function' ? children(renderProps) : children}
</div>
</MergeProvider>
)}
</Tag>
);
}
<MyTag><Button slot="remove" /></MyTag>
In this case <Button slot="remove" /> now will receive all of the props that RAC provides for the remove slot (className, aria attributes, event listeners, etc) plus any props that I want to add
My own version of mergeProps that supports render props for className and style:
import { type AsType } from '@cbc2/types';
import { clsx } from 'clsx';
import { type CSSProperties } from 'react';
import { mergeProps as mergePropsWithoutStyles } from 'react-aria';
import { composeRenderProps } from 'react-aria-components';
import {
type ClassNameRenderProps,
type RenderProps,
type StylePropRenderProps,
} from '../types';
type Props<T extends object> = AsType<T> | null | undefined;
/**
* Recursively process merging of all class name render props
*/
function processClassNameRenderProps<T extends RenderProps<object>>(
value: string,
renderProps: ClassNameRenderProps<object>,
...propsToMerge: Props<T>[]
): string {
if (!propsToMerge.length) {
return '';
}
const [props, ...rest] = propsToMerge;
return clsx(
value,
composeRenderProps<string, ClassNameRenderProps<object>, string>(
props?.className ?? '',
(prev) => processClassNameRenderProps(prev, renderProps, ...rest)
)(renderProps)
);
}
/**
* Compose class name render props to be processed and merged
*/
function mergeRenderClassNames<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
return composeRenderProps<string, ClassNameRenderProps<object>, string>(
(renderProps) => renderProps.defaultClassName ?? '',
(prev, renderProps) =>
processClassNameRenderProps(prev, renderProps, ...propsToMerge)
);
}
/**
* Merge static class names
*/
function mergePlainClassNames<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
return clsx(
propsToMerge.reduce<string[]>((acc, props) => {
if (typeof props?.className !== 'string') {
return acc;
}
return [...acc, props.className];
}, [])
);
}
/**
* Determine if a static or composed merge of class names is necesary based on the presence of functions
*/
function mergeClassNames<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
const anyFunctions = propsToMerge.some(
(props) => typeof props?.className === 'function'
);
const anyPrimitives = propsToMerge.some(
(props) => typeof props?.className === 'string'
);
if (!anyFunctions && !anyPrimitives) {
return null;
}
return anyFunctions
? mergeRenderClassNames(...propsToMerge)
: mergePlainClassNames(...propsToMerge);
}
/**
* Recursively process merging of all style render props
*/
function processStyleRenderProps<T extends RenderProps<object>>(
value: CSSProperties,
renderProps: StylePropRenderProps<object>,
...propsToMerge: Props<T>[]
): CSSProperties {
if (!propsToMerge.length) {
return {};
}
const [props, ...rest] = propsToMerge;
return {
...value,
...composeRenderProps<
CSSProperties,
StylePropRenderProps<object>,
CSSProperties
>(props?.style ?? {}, (prev) =>
processStyleRenderProps(prev, renderProps, ...rest)
)(renderProps),
};
}
/**
* Compose style render props to be processed and merged
*/
function mergeRenderStyles<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
return composeRenderProps<
CSSProperties,
StylePropRenderProps<object>,
CSSProperties
>(
(renderProps) => renderProps.defaultStyle ?? {},
(prev, renderProps) =>
processStyleRenderProps(prev, renderProps, ...propsToMerge)
);
}
/**
* Merge static styles
*/
function mergePlainStyles<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
return propsToMerge.reduce<CSSProperties>((acc, props) => {
if (!props?.style) {
return acc;
}
return {
...acc,
...props.style,
};
}, {});
}
/**
* Determine if a static or composed merge of styles is necesary based on the presence of functions
*/
function mergeStyles<T extends RenderProps<object>>(
...propsToMerge: Props<T>[]
) {
const anyFunctions = propsToMerge.some(
(props) => typeof props?.style === 'function'
);
const anyObjects = propsToMerge.some(
(props) => typeof props?.style === 'object' && props.style != null
);
if (!anyFunctions && !anyObjects) {
return null;
}
return anyFunctions
? mergeRenderStyles(...propsToMerge)
: mergePlainStyles(...propsToMerge);
}
/**
* Extends the base margeProps functionality to also merge styles and handle class/style render props
*/
export function mergeProps<T extends object>(...propsToMerge: Props<T>[]): T {
const className = mergeClassNames(...propsToMerge);
const style = mergeStyles(...propsToMerge);
return {
...(mergePropsWithoutStyles(...propsToMerge) as T),
...(className ? { className } : {}),
...(style ? { style } : {}),
};
}
It also makes it so that if you want to add in additional slots in the same context, you can do so without wiping out the slots from a higher version of the context. So, if I wanted to retain the remove slot and also add my own slot for a different opt in functionality, that would be possible too
Had to update Object.entries(nextSlots) to Reflect.ownKeys(nextSlots) due to RAC's use of symbols with DEFAULT_SLOT
I'm also looking for something similar. ~The way to use the MergeProvider in the OP does not cover other render props (className and style).~ UPDATE: it does, as we are talking about the className and style of the children.
IMO a better solution is add the merge logic directly in the Provider:
function MySelect(props) {
return (
<Provider values={[
[PopoverContext, { ... }, { merge: true }]
]}>
<Select { ...props } />
</Provider>
)
}
Currently, I have to either drop down to use the useSelect hook and replicate the implementation in react-aria-components, or roll my own context and create a wrapper for every component:
const MyPopoverContext = createContext()
function MyPopover(props, ref) {
;[props, ref] = useContextProps(props, ref, PopoverContext)
;[props, ref] = useContextProps(props, ref, MyPopoverContext)
...
}
function MySelect(props) {
return (
<Provider values={[
[MyPopoverContext, { ... }]
]}>
<Select { ...props } />
</Provider>
)
}
I do like your suggestion of having the merge logic be applied on a per context basis, I might update my own API to be structured that way. I've had a couple scenarios where I've needed to implement both a Provider and a MergeProvider, which is less ideal.
But having my own MergeProvider did allow for creation of my own custom mergeProps utility, that goes beyond the default mergeProps or useContextProps merge behavior, to support render props and more. All of that without introducing a breaking change to the established merge behavior in RAC.
One issue I notice is that it is not easy distinguish "override" or "compose". Using my example,
function MySelect({ className }) {
return (
<Provider values={[
[PopoverContext, { className }] // type mismatch
]} />
)
}
The className of Select and Popover has different types. The state in the handler are different.
So the code above doesn't work.
When the input className is a function, I need to create a wrapping callback, get the updated className result in runtime, extract the corresponding className that I want to apply to Popover (seems pretty impossible), and than modify the closure or build a new handler to be passed to the context.
And then, when there are two context storing the same prop, by default it will override, which will likely cause problems:
function MyPopover(props, ref) {
;[props, ref] = useContextProps(props, ref, PopoverContext)
// my `className` overrides the `className` set by `react-aria-components`
;[props, ref] = useContextProps(props, ref, MyPopoverContext)
...
}
Maybe have a look through how we're handling our contexts within our own design system. https://github.com/adobe/react-spectrum/pull/6856 This way types defined by our design system don't conflict with types defined for RAC.
Maybe have a look through how we're handling our contexts within our own design system.
Thanks for the example, btw what does s2 mean?
Next iteration of Adobe's design system https://s2.spectrum.adobe.com/