diff --git a/docs/pages/api-docs/button.md b/docs/pages/api-docs/button.md index 41e6fb529dc14d..53ef1362ffbfe5 100644 --- a/docs/pages/api-docs/button.md +++ b/docs/pages/api-docs/button.md @@ -41,7 +41,7 @@ The `MuiButton` name can be used for providing [default props](/customization/gl | href | string | | The URL to link to when the button is clicked. If defined, an `a` element will be used as the root node. | | size | 'large'
| 'medium'
| 'small'
| 'medium' | The size of the button. `small` is equivalent to the dense button styling. | | startIcon | node | | Element placed before the children. | -| variant | 'contained'
| 'outlined'
| 'text'
| 'text' | The variant to use. | +| variant | 'contained'
| 'outlined'
| 'text'
| string
| 'text' | The variant to use. | The `ref` is forwarded to the root element. diff --git a/docs/src/pages/customization/components/GlobalThemeVariants.js b/docs/src/pages/customization/components/GlobalThemeVariants.js new file mode 100644 index 00000000000000..9f1125f3eacc3e --- /dev/null +++ b/docs/src/pages/customization/components/GlobalThemeVariants.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { + createMuiTheme, + makeStyles, + ThemeProvider, +} from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; + +const useStyles = makeStyles((theme) => ({ + root: { + '& > *': { + margin: theme.spacing(1), + }, + }, +})); + +const defaultTheme = createMuiTheme(); + +const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'dashed' }, + styles: { + textTransform: 'none', + border: `2px dashed ${defaultTheme.palette.primary.main}`, + color: defaultTheme.palette.primary.main, + }, + }, + { + props: { variant: 'dashed', color: 'secondary' }, + styles: { + border: `2px dashed ${defaultTheme.palette.secondary.main}`, + color: defaultTheme.palette.secondary.main, + }, + }, + { + props: { variant: 'dashed', size: 'large' }, + styles: { + borderWidth: 4, + }, + }, + { + props: { variant: 'dashed', color: 'secondary', size: 'large' }, + styles: { + fontSize: 18, + }, + }, + ], + }, +}); + +export default function GlobalThemeVariants() { + const classes = useStyles(); + + return ( +
+ + + + + + +
+ ); +} diff --git a/docs/src/pages/customization/components/GlobalThemeVariants.tsx b/docs/src/pages/customization/components/GlobalThemeVariants.tsx new file mode 100644 index 00000000000000..7b7870bb589c58 --- /dev/null +++ b/docs/src/pages/customization/components/GlobalThemeVariants.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { + createMuiTheme, + makeStyles, + Theme, + ThemeProvider, +} from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; + +declare module '@material-ui/core/Button/Button' { + interface ButtonPropsVariantOverrides { + dashed: true; + } +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + '& > *': { + margin: theme.spacing(1), + }, + }, +})); + +const defaultTheme = createMuiTheme(); + +const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'dashed' }, + styles: { + textTransform: 'none', + border: `2px dashed ${defaultTheme.palette.primary.main}`, + color: defaultTheme.palette.primary.main, + }, + }, + { + props: { variant: 'dashed', color: 'secondary' }, + styles: { + border: `2px dashed ${defaultTheme.palette.secondary.main}`, + color: defaultTheme.palette.secondary.main, + }, + }, + { + props: { variant: 'dashed', size: 'large' }, + styles: { + borderWidth: 4, + }, + }, + { + props: { variant: 'dashed', color: 'secondary', size: 'large' }, + styles: { + fontSize: 18, + }, + }, + ], + }, +}); + +export default function GlobalThemeVariants() { + const classes = useStyles(); + + return ( +
+ + + + + + +
+ ); +} diff --git a/docs/src/pages/customization/components/components.md b/docs/src/pages/customization/components/components.md index 3af44a3abc80b9..906d0010aaf041 100644 --- a/docs/src/pages/customization/components/components.md +++ b/docs/src/pages/customization/components/components.md @@ -319,3 +319,43 @@ const theme = createMuiTheme({ ``` {{"demo": "pages/customization/components/GlobalThemeOverride.js"}} + +### Adding new component variants + +You can take advantage of the `variants` key of the `theme` to add new variants to Material-UI components. These new variants, can specify which styles the component should have, if specific properties are defined together. + +The definitions are specified in an array, under the component's name. For every one of them a class is added in the head. The order is **important**, so make sure that the styles that should win will be specified lastly. + +```jsx +const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'dashed' }, + styles: { + textTransform: 'none', + border: `2px dashed grey${blue[500]}`, + }, + }, + { + props: { variant: 'dashed', color: 'secondary' }, + styles: { + border: `4px dashed ${red[500]}`, + }, + }, + ], + }, +}); +``` + +If you are using TypeScript, you will need to specify your new variants/colors, using module augmentation. + +```tsx +declare module '@material-ui/core/Button/Button' { + interface ButtonPropsVariantOverrides { + dashed: true; + } +} +``` + +{{"demo": "pages/customization/components/GlobalThemeVariants.js"}} diff --git a/packages/material-ui-styles/src/getStylesCreator/getStylesCreator.js b/packages/material-ui-styles/src/getStylesCreator/getStylesCreator.js index 053a85973a63b0..1caeb3b55cf4a6 100644 --- a/packages/material-ui-styles/src/getStylesCreator/getStylesCreator.js +++ b/packages/material-ui-styles/src/getStylesCreator/getStylesCreator.js @@ -1,4 +1,5 @@ import { deepmerge } from '@material-ui/utils'; +import propsToClassKey from '../propsToClassKey'; import noopTheme from './noopTheme'; export default function getStylesCreator(stylesOrCreator) { @@ -36,11 +37,15 @@ export default function getStylesCreator(stylesOrCreator) { throw err; } - if (!name || !theme.overrides || !theme.overrides[name]) { + if ( + !name || + ((!theme.overrides || !theme.overrides[name]) && (!theme.variants || !theme.variants[name])) + ) { return styles; } - const overrides = theme.overrides[name]; + const overrides = (theme.overrides && theme.overrides[name]) || {}; + const variants = (theme.variants && theme.variants[name]) || []; const stylesWithOverrides = { ...styles }; Object.keys(overrides).forEach((key) => { @@ -50,12 +55,22 @@ export default function getStylesCreator(stylesOrCreator) { [ 'Material-UI: You are trying to override a style that does not exist.', `Fix the \`${key}\` key of \`theme.overrides.${name}\`.`, + '', + 'If you intentionally wanted to add a new key, please use the theme.variants option.', ].join('\n'), ); } } - stylesWithOverrides[key] = deepmerge(stylesWithOverrides[key], overrides[key]); + stylesWithOverrides[key] = deepmerge(stylesWithOverrides[key] || {}, overrides[key]); + }); + + variants.forEach((definition) => { + const classKey = propsToClassKey(definition.props); + stylesWithOverrides[classKey] = deepmerge( + stylesWithOverrides[classKey] || {}, + definition.styles, + ); }); return stylesWithOverrides; diff --git a/packages/material-ui-styles/src/index.d.ts b/packages/material-ui-styles/src/index.d.ts index 04f0677bfba08a..65d11adec915fe 100644 --- a/packages/material-ui-styles/src/index.d.ts +++ b/packages/material-ui-styles/src/index.d.ts @@ -31,6 +31,9 @@ export * from './ThemeProvider'; export { default as useTheme } from './useTheme'; export * from './useTheme'; +export { default as useThemeVariants } from './useThemeVariants'; +export * from './useThemeVariants'; + export { default as withStyles } from './withStyles'; export * from './withStyles'; diff --git a/packages/material-ui-styles/src/index.js b/packages/material-ui-styles/src/index.js index 55490730c6e870..dd9aac3207d387 100644 --- a/packages/material-ui-styles/src/index.js +++ b/packages/material-ui-styles/src/index.js @@ -58,6 +58,9 @@ export * from './ThemeProvider'; export { default as useTheme } from './useTheme'; export * from './useTheme'; +export { default as useThemeVariants } from './useThemeVariants'; +export * from './useThemeVariants'; + export { default as withStyles } from './withStyles'; export * from './withStyles'; diff --git a/packages/material-ui-styles/src/makeStyles/makeStyles.js b/packages/material-ui-styles/src/makeStyles/makeStyles.js index 113b1effd80d1d..cfa35bb1810577 100644 --- a/packages/material-ui-styles/src/makeStyles/makeStyles.js +++ b/packages/material-ui-styles/src/makeStyles/makeStyles.js @@ -241,6 +241,23 @@ export default function makeStyles(stylesOrCreator, options = {}) { // eslint-disable-next-line react-hooks/rules-of-hooks React.useDebugValue(classes); } + if (process.env.NODE_ENV !== 'production') { + const whitelistedComponents = ['MuiButton']; + + if ( + name && + whitelistedComponents.indexOf(name) >= 0 && + props.variant && + !classes[props.variant] + ) { + console.error( + [ + `Material-UI: You are using a variant value \`${props.variant}\` for which you didn't define styles.`, + `Please create a new variant matcher in your theme for this variant. To learn more about matchers visit https://material-ui.com/customization/components/#adding-new-component-variants.`, + ].join('\n'), + ); + } + } return classes; }; diff --git a/packages/material-ui-styles/src/propsToClassKey/index.d.ts b/packages/material-ui-styles/src/propsToClassKey/index.d.ts new file mode 100644 index 00000000000000..da423dd5fb1473 --- /dev/null +++ b/packages/material-ui-styles/src/propsToClassKey/index.d.ts @@ -0,0 +1,2 @@ +export { default } from './propsToClassKey'; +export * from './propsToClassKey'; diff --git a/packages/material-ui-styles/src/propsToClassKey/index.js b/packages/material-ui-styles/src/propsToClassKey/index.js new file mode 100644 index 00000000000000..89e04af61e6d6a --- /dev/null +++ b/packages/material-ui-styles/src/propsToClassKey/index.js @@ -0,0 +1 @@ +export { default } from './propsToClassKey'; diff --git a/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.d.ts b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.d.ts new file mode 100644 index 00000000000000..11cb34fce5f12a --- /dev/null +++ b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.d.ts @@ -0,0 +1 @@ +export default function propsToClassKey(props: object): string; diff --git a/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.js b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.js new file mode 100644 index 00000000000000..716fe6cd21acbc --- /dev/null +++ b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.js @@ -0,0 +1,38 @@ +import MuiError from '@material-ui/utils/macros/MuiError.macro'; + +// TODO: remove this once the capitalize method is moved to the @material-ui/utils package +export function capitalize(string) { + if (typeof string !== 'string') { + throw new MuiError('Material-UI: capitalize(string) expects a string argument.'); + } + + return string.charAt(0).toUpperCase() + string.slice(1); +} + +function isEmpty(string) { + return string.length === 0; +} + +/** + * Generates string classKey based on the properties provided. It starts with the + * variant if defined, and then it appends all other properties in alphabetical order. + * + * @param {object} props - the properties for which the classKey should be created + */ +export default function propsToClassKey(props) { + const { variant, ...rest } = props; + + let classKey = variant || ''; + + Object.keys(rest) + .sort() + .forEach((key) => { + if (key === 'color') { + classKey += isEmpty(classKey) ? props[key] : capitalize(props[key]); + } else { + classKey += `${isEmpty(classKey) ? key : capitalize(key)}${capitalize(props[key])}`; + } + }); + + return classKey; +} diff --git a/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.test.js b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.test.js new file mode 100644 index 00000000000000..dc885453acf415 --- /dev/null +++ b/packages/material-ui-styles/src/propsToClassKey/propsToClassKey.test.js @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import propsToClassKey from './propsToClassKey'; + +describe('propsToClassKey', () => { + it('should return the variant value as string', () => { + expect(propsToClassKey({ variant: 'custom' })).to.equal('custom'); + }); + + it('should combine the variant with other props', () => { + expect(propsToClassKey({ variant: 'custom', size: 'large' })).to.equal('customSizeLarge'); + }); + + it('should append the props after the variant in alphabetical order', () => { + expect(propsToClassKey({ variant: 'custom', size: 'large', mode: 'static' })).to.equal( + 'customModeStaticSizeLarge', + ); + }); + + it('should not prefix the color prop', () => { + expect(propsToClassKey({ variant: 'custom', color: 'primary' })).to.equal('customPrimary'); + }); + + it('should work without variant in props', () => { + expect(propsToClassKey({ color: 'primary', size: 'large', mode: 'static' })).to.equal( + 'primaryModeStaticSizeLarge', + ); + }); + + it('should not capitalize the first prop ', () => { + expect(propsToClassKey({ size: 'large', zIndex: 'toolbar' })).to.equal( + 'sizeLargeZIndexToolbar', + ); + }); +}); diff --git a/packages/material-ui-styles/src/useThemeVariants/index.d.ts b/packages/material-ui-styles/src/useThemeVariants/index.d.ts new file mode 100644 index 00000000000000..d01081d333c1d2 --- /dev/null +++ b/packages/material-ui-styles/src/useThemeVariants/index.d.ts @@ -0,0 +1,2 @@ +export { default } from './useThemeVariants'; +export * from './useThemeVariants'; diff --git a/packages/material-ui-styles/src/useThemeVariants/index.js b/packages/material-ui-styles/src/useThemeVariants/index.js new file mode 100644 index 00000000000000..2ee16edd774b5e --- /dev/null +++ b/packages/material-ui-styles/src/useThemeVariants/index.js @@ -0,0 +1 @@ +export { default } from './useThemeVariants'; diff --git a/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.d.ts b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.d.ts new file mode 100644 index 00000000000000..467b8a12699442 --- /dev/null +++ b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.d.ts @@ -0,0 +1 @@ +export default function useThemeVariants(props: object, name: string): string; diff --git a/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.js b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.js new file mode 100644 index 00000000000000..ac9d07028d2a1e --- /dev/null +++ b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.js @@ -0,0 +1,28 @@ +import useTheme from '../useTheme'; +import propsToClassKey from '../propsToClassKey'; + +const useThemeVariants = (props, name) => { + const { classes = {} } = props; + const theme = useTheme(); + + let variantsClasses = ''; + if (theme && theme.variants && theme.variants[name]) { + const themeVariants = theme.variants[name]; + + themeVariants.forEach((themeVariant) => { + let isMatch = true; + Object.keys(themeVariant.props).forEach((key) => { + if (props[key] !== themeVariant.props[key]) { + isMatch = false; + } + }); + if (isMatch) { + variantsClasses = `${variantsClasses}${classes[propsToClassKey(themeVariant.props)]} `; + } + }); + } + + return variantsClasses; +}; + +export default useThemeVariants; diff --git a/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.test.js b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.test.js new file mode 100644 index 00000000000000..0a1fdf4f344441 --- /dev/null +++ b/packages/material-ui-styles/src/useThemeVariants/useThemeVariants.test.js @@ -0,0 +1,97 @@ +import { expect } from 'chai'; +import React from 'react'; +import { createClientRender, screen } from 'test/utils'; +import { createMuiTheme } from '@material-ui/core/styles'; +import ThemeProvider from '../ThemeProvider'; +import useThemeVariants from './useThemeVariants'; +import withStyles from '../withStyles'; + +describe('useThemeVariants', () => { + const render = createClientRender(); + + const ComponentInternal = (props) => { + const { className, ...other } = props; + const themeVariantsClasses = useThemeVariants(props, 'Test'); + return
; + }; + + const Component = withStyles({}, { name: 'Test' })(ComponentInternal); + + it('returns variants classes if props do match', () => { + const theme = createMuiTheme({ + variants: { + Test: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + ], + }, + }); + + render( + + + Test + + , + ); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).to.equal('rgb(255, 0, 0)'); + }); + + it('does not return variants classes if props do not match', () => { + const theme = createMuiTheme({ + variants: { + Test: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + ], + }, + }); + + render( + + Test + , + ); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).not.to.equal('rgb(255, 0, 0)'); + }); + + it('matches correctly multiple props', () => { + const theme = createMuiTheme({ + variants: { + Test: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + { + props: { variant: 'test', color: 'primary' }, + styles: { backgroundColor: 'rgb(255, 255, 0)' }, + }, + { + props: { variant: 'test', color: 'secondary' }, + styles: { backgroundColor: 'rgb(0, 0, 255)' }, + }, + ], + }, + }); + + render( + + + Test + + , + ); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).to.equal('rgb(255, 255, 0)'); + }); +}); diff --git a/packages/material-ui-styles/src/withStyles/withStyles.test.js b/packages/material-ui-styles/src/withStyles/withStyles.test.js index 2d61a678c8c8f0..9b4c194ead6262 100644 --- a/packages/material-ui-styles/src/withStyles/withStyles.test.js +++ b/packages/material-ui-styles/src/withStyles/withStyles.test.js @@ -235,6 +235,47 @@ describe('withStyles', () => { expect(sheetsRegistry.registry[0].rules.raw).to.deep.equal({ root: { padding: 9 } }); }); + it('should support the variants key', () => { + const styles = {}; + const StyledComponent = withStyles(styles, { name: 'MuiButton' })(() =>
); + const generateClassName = createGenerateClassName(); + const sheetsRegistry = new SheetsRegistry(); + + render( + + + + + , + ); + + expect(sheetsRegistry.registry.length).to.equal(1); + expect(sheetsRegistry.registry[0].rules.raw).to.deep.equal({ + test: { padding: 9 }, + testSizeLarge: { fontSize: 20 }, + sizeLargest: { fontSize: 22 }, + }); + }); + describe('options: disableGeneration', () => { it('should not generate the styles', () => { const styles = { root: { display: 'flex' } }; diff --git a/packages/material-ui/src/Button/Button.d.ts b/packages/material-ui/src/Button/Button.d.ts index 7287d5bc4242a5..023222cf43b7b7 100644 --- a/packages/material-ui/src/Button/Button.d.ts +++ b/packages/material-ui/src/Button/Button.d.ts @@ -1,6 +1,10 @@ +import { OverridableStringUnion } from '@material-ui/types'; import { ExtendButtonBase, ExtendButtonBaseTypeMap } from '../ButtonBase'; import { OverrideProps, OverridableComponent, OverridableTypeMap } from '../OverridableComponent'; +export interface ButtonPropsVariantOverrides {} +export type VariantDefaults = Record<'text' | 'outlined' | 'contained', true>; + export type ButtonTypeMap< P = {}, D extends React.ElementType = 'button' @@ -51,7 +55,7 @@ export type ButtonTypeMap< /** * The variant to use. */ - variant?: 'text' | 'outlined' | 'contained'; + variant?: OverridableStringUnion; }; defaultComponent: D; classKey: ButtonClassKey; diff --git a/packages/material-ui/src/Button/Button.js b/packages/material-ui/src/Button/Button.js index 332172cc569319..427e1a21d0cce9 100644 --- a/packages/material-ui/src/Button/Button.js +++ b/packages/material-ui/src/Button/Button.js @@ -1,6 +1,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; +import { useThemeVariants } from '@material-ui/styles'; import withStyles from '../styles/withStyles'; import { fade } from '../styles/colorManipulator'; import ButtonBase from '../ButtonBase'; @@ -278,6 +279,22 @@ const Button = React.forwardRef(function Button(props, ref) { ...other } = props; + const themeVariantsClasses = useThemeVariants( + { + ...props, + color, + component, + disabled, + disableElevation, + disableFocusRipple, + fullWidth, + size, + type, + variant, + }, + 'MuiButton', + ); + const startIcon = startIconProp && ( {startIconProp} @@ -304,6 +321,7 @@ const Button = React.forwardRef(function Button(props, ref) { [classes.fullWidth]: fullWidth, [classes.colorInherit]: color === 'inherit', }, + themeVariantsClasses, className, )} component={component} @@ -408,7 +426,10 @@ Button.propTypes = { /** * The variant to use. */ - variant: PropTypes.oneOf(['contained', 'outlined', 'text']), + variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['contained', 'outlined', 'text']), + PropTypes.string, + ]), }; export default withStyles(styles, { name: 'MuiButton' })(Button); diff --git a/packages/material-ui/src/Button/Button.test.js b/packages/material-ui/src/Button/Button.test.js index e97c63e07471c3..a57507fd84cc49 100644 --- a/packages/material-ui/src/Button/Button.test.js +++ b/packages/material-ui/src/Button/Button.test.js @@ -1,4 +1,5 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import { expect } from 'chai'; import { getClasses, @@ -6,9 +7,11 @@ import { describeConformance, act, createClientRender, + screen, fireEvent, createServerRender, } from 'test/utils'; +import { ThemeProvider, createMuiTheme } from '../styles'; import Button from './Button'; import ButtonBase from '../ButtonBase'; @@ -367,4 +370,150 @@ describe(' + + ); + }; + + WrappedComponent.propTypes = { + theme: PropTypes.object, + }; + + it('should map the variant classkey to the component', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // see https://github.com/jsdom/jsdom/issues/2953 + this.skip(); + } + + const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + ], + }, + }); + + render(); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).to.equal('rgb(255, 0, 0)'); + }); + + it('should map the latest props combination classkey to the component', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // see https://github.com/jsdom/jsdom/issues/2953 + this.skip(); + } + + const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + { + props: { variant: 'test', size: 'large', color: 'primary' }, + styles: { backgroundColor: 'rgb(0, 255, 0)' }, + }, + ], + }, + }); + + render(); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).to.equal('rgb(0, 255, 0)'); + }); + + it('should not add classKey if not all props match', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // see https://github.com/jsdom/jsdom/issues/2953 + this.skip(); + } + + const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + { + props: { variant: 'test', size: 'large' }, + styles: { backgroundColor: 'rgb(0, 255, 0)' }, + }, + ], + }, + }); + + render(); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).not.to.equal('rgb(0, 255, 0)'); + }); + + it('should consider default props when matching the props', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // see https://github.com/jsdom/jsdom/issues/2953 + this.skip(); + } + + const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'test' }, + styles: { backgroundColor: 'rgb(0, 0, 0)' }, + }, + { + props: { variant: 'test', color: 'primary', size: 'large' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + ], + }, + }); + + render(); + + const style = window.getComputedStyle(screen.getByTestId('component')); + expect(style.getPropertyValue('background-color')).to.equal('rgb(255, 0, 0)'); + }); + + it('should warn if the used variant is not defined in the theme', function test() { + const theme = createMuiTheme({ + variants: { + MuiButton: [ + { + props: { variant: 'test1' }, + styles: { backgroundColor: 'rgb(255, 0, 0)' }, + }, + ], + }, + }); + + expect(() => mount()).toErrorDev([ + // strict mode renders twice + [ + `Material-UI: You are using a variant value \`test\` for which you didn't define styles.`, + `Please create a new variant matcher in your theme for this variant. To learn more about matchers visit https://material-ui.com/customization/components/#adding-new-component-variants.`, + ].join('\n'), + [ + `Material-UI: You are using a variant value \`test\` for which you didn't define styles.`, + `Please create a new variant matcher in your theme for this variant. To learn more about matchers visit https://material-ui.com/customization/components/#adding-new-component-variants.`, + ].join('\n'), + ]); + }); + }); }); diff --git a/packages/material-ui/src/styles/createMuiTheme.d.ts b/packages/material-ui/src/styles/createMuiTheme.d.ts index 4bfe849c8d7118..f0191dd2d92be5 100644 --- a/packages/material-ui/src/styles/createMuiTheme.d.ts +++ b/packages/material-ui/src/styles/createMuiTheme.d.ts @@ -8,6 +8,7 @@ import { Spacing, SpacingOptions } from './createSpacing'; import { Transitions, TransitionsOptions } from './transitions'; import { ZIndex, ZIndexOptions } from './zIndex'; import { Overrides } from './overrides'; +import { Variants } from './variants'; import { ComponentsProps } from './props'; export type Direction = 'ltr' | 'rtl'; @@ -24,6 +25,7 @@ export interface ThemeOptions { spacing?: SpacingOptions; transitions?: TransitionsOptions; typography?: TypographyOptions | ((palette: Palette) => TypographyOptions); + variants?: Variants; zIndex?: ZIndexOptions; unstable_strictMode?: boolean; } @@ -40,6 +42,7 @@ export interface Theme { spacing: Spacing; transitions: Transitions; typography: Typography; + variants?: Variants; zIndex: ZIndex; unstable_strictMode?: boolean; } diff --git a/packages/material-ui/src/styles/createMuiTheme.js b/packages/material-ui/src/styles/createMuiTheme.js index b679500db0bca4..da1c8afcfbea45 100644 --- a/packages/material-ui/src/styles/createMuiTheme.js +++ b/packages/material-ui/src/styles/createMuiTheme.js @@ -36,6 +36,7 @@ function createMuiTheme(options = {}, ...args) { spacing, shape, transitions, + variants: {}, zIndex, }, other, diff --git a/packages/material-ui/src/styles/variants.d.ts b/packages/material-ui/src/styles/variants.d.ts new file mode 100644 index 00000000000000..2e6873fce532c8 --- /dev/null +++ b/packages/material-ui/src/styles/variants.d.ts @@ -0,0 +1,17 @@ +import { CSSProperties, CreateCSSProperties, PropsFunc } from '@material-ui/styles/withStyles'; +import { ComponentsPropsList } from './props'; + +export type Variants = { + [Name in keyof ComponentsPropsList]?: Array<{ + props: Partial; + styles: // JSS property bag + | CSSProperties + // JSS property bag where values are based on props + | CreateCSSProperties> + // JSS property bag based on props + | PropsFunc< + Partial, + CreateCSSProperties> + >; + }>; +};