Search code examples
reactjstypescriptmaterial-uireact-proptypes

How to type classes when passing them as a prop in material ui


I find myself writing a few generic components in material ui, where the styling is actually defined in a parent or grandparent component. For example:

const GenericDescendant = (props: DescendantProps) => {
  const { classesFromAncestor, ...other } = props
  return <Button className={classesFromAncestor} {...props}>Text here</Button>
}

Where from the Ancestor, I create a useStyles and classes object and pass it down:

const useDescendantStyles = makeStyles({
  selector: { 
    border: '1px solid green',
    // more customized styles
  }
})

const Ancestor = () => {
  const descendantClasses = useDescendantStyles()
  return <GenericDescendant classesFromAncestor={descendantClasses} />
}

Or another way, I can use withStyles to wrap a component used in the descendant:

const GenericDescendant = (props: DescendantProps) => {
  const { tooltipClassesFromAncestor, buttonClassesFromAncestor, ...other } = props
  const CustmoizedTooltip = withStyles(tooltipClassesFromAncestor)(Tooltip);
  const CustomButton = withStyles(buttonClassesFromAncestor)(Button)
  return (
    <CustmoizedTooltip>
      <CustomButton>Text here</CustomButton>
    </CustmoizedTooltip>
  )
}

In this case, I wouldn't call useStyles in the Ancestor, I would just pass the object like this:

const Ancestor = () => {
  return <GenericDescendant classesFromAncestor={{
    selector: { 
      border: '1px solid green',
      // more customized styles
    }
  }} />
}

I don't want to just wrap the whole GenericDescendant in a withStyles, because then I can't target which component is taking the custom styles being passed from the ancestor.

The cases are slightly different, but I can go either route to allow customization of a generic descendant from the ancestor. However, when passing the customized styles as a prop, I don't know how to properly type the classesFromAncestor prop in DescendantProps. It could really take any form, and that form won't be known until runtime. I don't want to type it as any. What's the best way to define the type of classes that are passed as a prop?


Solution

  • I've been playing with this and there are many possible setups. You can style a material-ui component by passing a single string className and you can also pass an object of classes which correspond to the different selectors available for that component. The primary selector for most (but not all) Material components is root. A Button accepts classes root, label, text, and many others.

    When you call makeStyles what you create is an object where the keys are the selectors, basically the same keys as your input, and the values are the string class names for the generated classes. You can import this type as ClassNameMap from @material-ui/styles. It is essentially Record<string, string>, but with an optional generic that lets you restrict the keys.

    export type ClassNameMap<ClassKey extends string = string> = Record<ClassKey, string>;
    

    The first argument that you pass to makeStyles or withStyles is your keyed object of styles properties. It can be simplified to Record<string, CSSProperties> or you can import the complex type from material-ui which includes support for functions, etc. The type Styles<Theme, Props, ClassKey> takes three optional generics where Theme is the type of your theme, Props is the component's own props, and ClassKey is again the supported selectors. Theme and Props are mainly included so that you can map your styles as a function of theme or props.

    If we want to provide styles to multiple components at once, a simplistic way would be to create a styles object with one key for each component. You can call them anything. We then pass each of those styles as the className to the component.

    const MyStyled = withStyles({
      button: {
        border: "green",
        background: "yellow"
      },
      checkbox: {
        background: "red"
      }
    })(({ classes }: { classes: ClassNameMap }) => (
      <Button className={classes.button}>
        <Checkbox className={classes.checkbox} />
        Text Here
      </Button>
    ));
    

    That setup allows for any number of components at any level of nesting.

    I played a bit with making a higher-order component that can wrap your outer component. You can use this as a starting point and tweak it to your needs.

    const withAncestralStyles = <Props extends { className?: string } = {}>(
      Component: ComponentType<Props>,
      style: Styles<DefaultTheme, Omit<Props, "children">, string>
    ) => {
      const useStyles = makeStyles(style);
    
      return (
        props: Omit<Props, "children"> & {
          children?: (styles: ClassNameMap) => ReactNode;
        }
      ) => {
        console.log(props);
        const classes = useStyles(props);
        const { children, className, ...rest } = props;
        return (
          <Component {...rest as Props} className={`${className} ${classes.root}`}>
            {children ? children(classes) : undefined}
          </Component>
        );
      };
    };
    

    Basically, the class classes.root will get automatically applied to the className of the parent while all styles are passed to the child as props (but not applied). I had trouble making React.cloneElement work, so I am requiring that the children of the component be a function which receives the classes object.

    const StyledButton = withAncestralStyles(Button, {
      root: {
        border: "green",
        background: "yellow"
      },
      checkbox: {
        background: "red"
      }
    });
    
    const MyComponent = () => (
      <StyledButton onClick={() => alert('clicked')}>
        {(classes) => <><Checkbox className={classes.checkbox}/>Some Text</>}
      </StyledButton>
    );
    

    Code Sandbox

    This approach did not work properly with your Tooltip example because Tooltip is one of the components that does not have a root selector and needs to be styled differently to target the popover.