Skip to content

[core] Support merging of className and style from theme #45975

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

Merged
merged 47 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
efbe160
[core] Support merging of className and style from theme
sai6855 Apr 21, 2025
79dfa9a
fix logic
sai6855 Apr 21, 2025
0b9c7f0
fix logic
sai6855 Apr 21, 2025
797d83c
add teheme test
sai6855 Apr 21, 2025
0a58a00
fix test
sai6855 Apr 22, 2025
ffc6897
fix test
sai6855 Apr 22, 2025
55887c7
fix
sai6855 Apr 22, 2025
e214261
update logic
sai6855 Apr 22, 2025
e41ce88
add test
sai6855 Apr 22, 2025
550024e
fix tests
sai6855 Apr 22, 2025
bf09468
fix logic
sai6855 Apr 22, 2025
14d7410
fix tests
sai6855 Apr 22, 2025
904d2d1
fix tests
sai6855 Apr 22, 2025
c212733
prettier
sai6855 Apr 22, 2025
cbbf441
trigger CI
sai6855 Apr 22, 2025
970f099
fix tests
sai6855 Apr 22, 2025
9cdb15c
improve logic
sai6855 Apr 23, 2025
2b53472
Trigger Build
sai6855 Apr 23, 2025
15972e5
suggestions: code-review
sai6855 May 19, 2025
585bdc9
Merge branch 'master' into merge-classname-style
sai6855 May 19, 2025
f566486
trigger CI
sai6855 May 19, 2025
9ce9f84
Merge branch 'master' into merge-classname-style
sai6855 May 19, 2025
b3e86a9
comment cardheader tests
sai6855 May 22, 2025
210006e
add test back
sai6855 May 22, 2025
e3ee1f1
remove from d.ts
sai6855 May 22, 2025
e6bc427
add usetheme
sai6855 May 22, 2025
3bf9f64
add createthemewithvars
sai6855 May 22, 2025
4167360
add createthemewithvars
sai6855 May 22, 2025
750f406
fix types
sai6855 May 23, 2025
c49f00b
fix
sai6855 May 23, 2025
08be71f
Merge branch 'master' into merge-classname-style
sai6855 May 23, 2025
9bb39c8
add ts test
sai6855 May 23, 2025
4f57634
Merge branch 'merge-classname-style' of https://github.com/sai6855/ma…
sai6855 May 23, 2025
cc61c2f
move tests inside describe block
sai6855 Jun 9, 2025
90c1b81
Merge branch 'master' into merge-classname-style
sai6855 Jun 9, 2025
44065a3
add documentation
sai6855 Jun 9, 2025
3454016
Merge branch 'merge-classname-style' of https://github.com/sai6855/ma…
sai6855 Jun 9, 2025
f8edeb0
prettier
sai6855 Jun 9, 2025
dd313e5
Update theming.md
sai6855 Jun 9, 2025
ba9f839
Update docs/data/material/customization/theming/theming.md
sai6855 Jun 10, 2025
af72aea
Update docs/data/material/customization/theming/theming.md
sai6855 Jun 10, 2025
bb12588
sam review
sai6855 Jun 10, 2025
c3f21b5
prettier
sai6855 Jun 10, 2025
f38ef9e
modify docs
sai6855 Jun 10, 2025
465ed63
pnpm prettier
sai6855 Jun 10, 2025
714c03f
fix docs
sai6855 Jun 10, 2025
dafbfa1
prettier
sai6855 Jun 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/data/material/customization/theming/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,61 @@ Think of creating a theme as a two-step composition process: first, you define t

**WARNING**: `theme.vars` is a private field used for CSS variables support. Please use another name for a custom object.

### Merging className and style props in defaultProps

By default, when a component has `defaultProps` defined in the theme, props passed to the component override the default props completely.

```jsx
import { createTheme } from '@mui/material/styles';

const theme = createTheme({
components: {
MuiButton: {
defaultProps: {
className: 'default-button-class',
style: { marginTop: 8 },
},
},
},
});

// className will be: "custom-button-class" (default ignored)
// style will be: { color: 'blue' } (default ignored)
<Button className="custom-button-class" style={{ color: 'blue' }}>
Click me
</Button>;
```

You can change this behavior by configuring the theme to merge `className` and `style` props instead of replacing them.

To do this, set `theme.components.mergeClassNameAndStyle` to `true`:

```jsx
import { createTheme } from '@mui/material/styles';

const theme = createTheme({
components: {
mergeClassNameAndStyle: true,
MuiButton: {
defaultProps: {
className: 'default-button-class',
style: { marginTop: 8 },
},
},
},
});
```

Here's what the example above looks like with this configuration:

```jsx
// className will be: "default-button-class custom-button-class"
// style will be: { marginTop: 8, color: 'blue' }
<Button className="custom-button-class" style={{ color: 'blue' }}>
Click me
</Button>
```

### `responsiveFontSizes(theme, options) => theme`

Generate responsive typography settings based on the options received.
Expand Down
104 changes: 104 additions & 0 deletions packages/mui-material/src/CardHeader/CardHeader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { typographyClasses } from '@mui/material/Typography';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import CardHeader, { cardHeaderClasses as classes } from '@mui/material/CardHeader';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import describeConformance from '../../test/describeConformance';

describe('<CardHeader />', () => {
Expand Down Expand Up @@ -106,4 +107,107 @@ describe('<CardHeader />', () => {
expect(subHeader).to.have.class(typographyClasses.body2);
});
});

it('should merge className and style from props and from the theme if mergeClassNameAndStyle is true', () => {
const { container } = render(
<ThemeProvider
theme={createTheme({
components: {
mergeClassNameAndStyle: true,
MuiCardHeader: {
defaultProps: {
className: 'theme-class',
style: { margin: '10px' },
slotProps: {
root: {
className: 'theme-slot-props-root-class',
style: {
fontSize: '10px',
},
},
title: {
className: 'theme-slot-props-title-class',
},
},
},
},
},
})}
>
<CardHeader
title="Title"
subheader="Subheader"
className="component-class"
style={{ padding: '10px' }}
slotProps={{
title: {
className: 'slot-props-title-class',
},
root: {
className: 'slot-props-root-class',
style: {
fontWeight: 'bold',
},
},
}}
/>
</ThemeProvider>,
);
const cardHeader = container.querySelector(`.${classes.root}`);
expect(cardHeader).to.have.class('theme-class');
expect(cardHeader).to.have.class('component-class');
expect(cardHeader).to.have.class('theme-slot-props-root-class');
expect(cardHeader).to.have.class('slot-props-root-class');
expect(cardHeader.style.margin).to.equal('10px'); // from theme
expect(cardHeader.style.padding).to.equal('10px'); // from props
expect(cardHeader.style.fontWeight).to.equal('bold'); // from props slotProps
expect(cardHeader.style.fontSize).to.equal('10px'); // from theme slotProps

const title = container.querySelector(`.${classes.title}`);
expect(title).to.have.class('theme-slot-props-title-class');
expect(title).to.have.class('slot-props-title-class');
});

it('should not merge className and style from props and from the theme if mergeClassNameAndStyle is false', () => {
render(
<ThemeProvider
theme={createTheme({
components: {
MuiCardHeader: {
defaultProps: {
className: 'test-class-1',
style: { margin: '10px' },
slotProps: {
title: {
className: 'title-class-1',
},
},
},
},
},
})}
>
<CardHeader
title="Title"
subheader="Subheader"
className="test-class-2"
style={{ padding: '10px' }}
slotProps={{
title: {
className: 'title-class-2',
},
}}
/>
</ThemeProvider>,
);
const cardHeader = document.querySelector(`.${classes.root}`);
expect(cardHeader).to.not.have.class('test-class-1');
expect(cardHeader).to.have.class('test-class-2');
expect(cardHeader).to.not.have.style('margin', '10px');
expect(cardHeader).to.have.style('padding', '10px');

const title = cardHeader.querySelector(`.${classes.title}`);
expect(title).to.not.have.class('title-class-1');
expect(title).to.have.class('title-class-2');
});
});
5 changes: 5 additions & 0 deletions packages/mui-material/src/styles/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { ComponentsOverrides } from './overrides';
import { ComponentsVariants } from './variants';

export interface Components<Theme = unknown> {
/**
* Whether to merge the className and style coming from the component props with the default props.
* @default false
*/
mergeClassNameAndStyle?: boolean;
MuiAlert?: {
defaultProps?: ComponentsProps['MuiAlert'];
styleOverrides?: ComponentsOverrides<Theme>['MuiAlert'];
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-material/src/styles/createTheme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,11 @@ const theme = createTheme();
},
});
}

{
createTheme({
components: {
mergeClassNameAndStyle: true,
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ DefaultPropsProvider.propTypes /* remove-proptypes */ = {

function getThemeProps<
Theme extends {
components?: Record<string, { defaultProps?: any; styleOverrides?: any; variants?: any }>;
components?: Record<string, { defaultProps?: any; styleOverrides?: any; variants?: any }> & {
mergeClassNameAndStyle?: boolean;
};
},
Props,
Name extends string,
Expand All @@ -43,12 +45,12 @@ function getThemeProps<

if (config.defaultProps) {
// compatible with v5 signature
return resolveProps(config.defaultProps, props);
return resolveProps(config.defaultProps, props, theme.components.mergeClassNameAndStyle);
}

if (!config.styleOverrides && !config.variants) {
// v6 signature, no property 'defaultProps'
return resolveProps(config as any, props);
return resolveProps(config as any, props, theme.components.mergeClassNameAndStyle);
}
return props;
}
Expand Down
45 changes: 45 additions & 0 deletions packages/mui-utils/src/resolveProps/resolveProps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,49 @@ describe('resolveProps', () => {
notTheSlotProps: { className: 'input' },
});
});

describe('param: mergeClassNameAndStyle', () => {
it('merge className and style props', () => {
expect(
resolveProps(
{ className: 'input1', style: { color: 'red' } },
{ className: 'input2', style: { backgroundColor: 'blue' } },
true,
),
).to.deep.equal({
className: 'input1 input2',
style: { color: 'red', backgroundColor: 'blue' },
});
});

it('merge className props', () => {
expect(resolveProps({ className: 'input1' }, { className: 'input2' }, true)).to.deep.equal({
className: 'input1 input2',
});

expect(resolveProps({ className: 'input1' }, {}, true)).to.deep.equal({
className: 'input1',
});

expect(resolveProps({}, { className: 'input2' }, true)).to.deep.equal({
className: 'input2',
});
});

it('merge style props', () => {
expect(
resolveProps({ style: { color: 'red' } }, { style: { backgroundColor: 'blue' } }, true),
).to.deep.equal({
style: { color: 'red', backgroundColor: 'blue' },
});

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

expect(resolveProps({}, { style: { backgroundColor: 'blue' } }, true)).to.deep.equal({
style: { backgroundColor: 'blue' },
});
});
});
});
17 changes: 16 additions & 1 deletion packages/mui-utils/src/resolveProps/resolveProps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import clsx from 'clsx';

/**
* Add keys, values of `defaultProps` that does not exist in `props`
* @param defaultProps
* @param props
* @param mergeClassNameAndStyle If `true`, merges `className` and `style` props instead of overriding them.
* When `false` (default), props override defaultProps. When `true`, `className` values are concatenated
* and `style` objects are merged with props taking precedence.
* @returns resolved props
*/
export default function resolveProps<
Expand All @@ -10,8 +15,10 @@ export default function resolveProps<
componentsProps?: Record<string, unknown>;
slots?: Record<string, unknown>;
slotProps?: Record<string, unknown>;
className?: string;
style?: React.CSSProperties;
} & Record<string, unknown>,
>(defaultProps: T, props: T) {
>(defaultProps: T, props: T, mergeClassNameAndStyle: boolean = false) {
const output = { ...props };

for (const key in defaultProps) {
Expand Down Expand Up @@ -40,10 +47,18 @@ export default function resolveProps<
(output[propName] as Record<string, unknown>)[slotPropName] = resolveProps(
(defaultSlotProps as Record<string, any>)[slotPropName],
(slotProps as Record<string, any>)[slotPropName],
mergeClassNameAndStyle,
);
}
}
}
} else if (propName === 'className' && mergeClassNameAndStyle && props.className) {
output.className = clsx(defaultProps?.className, props?.className);
} else if (propName === 'style' && mergeClassNameAndStyle && props.style) {
output.style = {
...defaultProps?.style,
...props?.style,
};
} else if (output[propName] === undefined) {
output[propName] = defaultProps[propName];
}
Expand Down