Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MergeProvider #6610

Open
ArrayKnight opened this issue Jun 24, 2024 · 3 comments
Open

MergeProvider #6610

ArrayKnight opened this issue Jun 24, 2024 · 3 comments

Comments

@ArrayKnight
Copy link

ArrayKnight commented Jun 24, 2024

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

@LFDanLu
Copy link
Member

LFDanLu commented Jun 26, 2024

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.

@ArrayKnight
Copy link
Author

ArrayKnight commented Jun 27, 2024

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 } : {}),
  };
}

@ArrayKnight
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants