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?
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>
);
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.