diff --git a/docs/data/material/migration/migrating-from-deprecated-apis/migrating-from-deprecated-apis.md b/docs/data/material/migration/migrating-from-deprecated-apis/migrating-from-deprecated-apis.md index d1f997e86a1ff4..cfd2b504ea6f9d 100644 --- a/docs/data/material/migration/migrating-from-deprecated-apis/migrating-from-deprecated-apis.md +++ b/docs/data/material/migration/migrating-from-deprecated-apis/migrating-from-deprecated-apis.md @@ -1465,6 +1465,39 @@ Here's how to migrate: }, ``` +## Tab + +Use the [codemod](https://github.com/mui/material-ui/tree/HEAD/packages/mui-codemod#tab-classes) below to migrate the code as described in the following sections: + +```bash +npx @mui/codemod@next deprecations/tab-classes +``` + +### Composed CSS classes + +The `iconWrapper` class is removed. + +Here's how to migrate: + +```diff +- .MuiTab-iconWrapper ++ .MuiTab-icon +``` + +```diff + import { tabClasses } from '@mui/material/Tab'; + + MuiTab: { + styleOverrides: { + root: { +- [`& .${tabClasses.iconWrapper}`]: { ++ [`& .${tabClasses.icon}`]: { + color: 'red', + }, + }, + }, +``` + ## TableSortLabel Use the [codemod](https://github.com/mui/material-ui/tree/HEAD/packages/mui-codemod#table-sort-label-classes) below to migrate the code as described in the following sections: diff --git a/docs/pages/material-ui/api/tab.json b/docs/pages/material-ui/api/tab.json index 623ae2928d01ca..2c0ca347afe058 100644 --- a/docs/pages/material-ui/api/tab.json +++ b/docs/pages/material-ui/api/tab.json @@ -39,11 +39,18 @@ "description": "Styles applied to the root element if `fullWidth={true}` (controlled by the Tabs component).", "isGlobal": false }, + { + "key": "icon", + "className": "MuiTab-icon", + "description": "Styles applied to the `icon` HTML element if both `icon` and `label` are provided.", + "isGlobal": false + }, { "key": "iconWrapper", "className": "MuiTab-iconWrapper", "description": "Styles applied to the `icon` HTML element if both `icon` and `label` are provided.", - "isGlobal": false + "isGlobal": false, + "isDeprecated": true }, { "key": "labelIcon", diff --git a/docs/translations/api-docs/tab/tab.json b/docs/translations/api-docs/tab/tab.json index 7a4473a167f03e..935d49314c2aef 100644 --- a/docs/translations/api-docs/tab/tab.json +++ b/docs/translations/api-docs/tab/tab.json @@ -36,11 +36,17 @@ "nodeName": "the root element", "conditions": "fullWidth={true} (controlled by the Tabs component)" }, - "iconWrapper": { + "icon": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the icon HTML element", "conditions": "both icon and label are provided" }, + "iconWrapper": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the icon HTML element", + "conditions": "both icon and label are provided", + "deprecationInfo": "Use icon class instead. See Migrating from deprecated APIs for more details" + }, "labelIcon": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", diff --git a/packages/mui-codemod/README.md b/packages/mui-codemod/README.md index 2035f38e06f13e..bcf8b4090895f8 100644 --- a/packages/mui-codemod/README.md +++ b/packages/mui-codemod/README.md @@ -1510,6 +1510,35 @@ CSS transforms: npx @mui/codemod@next deprecations/step-connector-classes ``` +#### `tab-classes` + +JS transforms: + +```diff + import { tabClasses } from '@mui/material/Tab'; + + MuiTab: { + styleOverrides: { + root: { +- [`& .${tabClasses.iconWrapper}`]: { ++ [`& .${tabClasses.icon}`]: { + color: 'red', + }, + }, + }, +``` + +CSS transforms: + +```diff +- .MuiTab-iconWrapper ++ .MuiTab-icon +``` + +```bash +npx @mui/codemod@next deprecations/tab-classes +``` + #### `table-sort-label-classes` JS transforms: diff --git a/packages/mui-codemod/src/deprecations/all/deprecations-all.js b/packages/mui-codemod/src/deprecations/all/deprecations-all.js index 0b3032fd536a05..7cc1c3964126d0 100644 --- a/packages/mui-codemod/src/deprecations/all/deprecations-all.js +++ b/packages/mui-codemod/src/deprecations/all/deprecations-all.js @@ -23,6 +23,7 @@ import transformTableSortLabelClasses from '../table-sort-label-classes'; import transformStepConnectorClasses from '../step-connector-classes'; import transformStepLabelProps from '../step-label-props'; import transformTextFieldProps from '../text-field-props'; +import transformTabClasses from '../tab-classes'; import transformToggleButtonGroupClasses from '../toggle-button-group-classes'; /** @@ -55,6 +56,7 @@ export default function deprecationsAll(file, api, options) { file.source = transformStepLabelProps(file, api, options); file.source = transformTableSortLabelClasses(file, api, options); file.source = transformTextFieldProps(file, api, options); + file.source = transformTabClasses(file, api, options); file.source = transformToggleButtonGroupClasses(file, api, options); return file.source; diff --git a/packages/mui-codemod/src/deprecations/all/postcss.config.js b/packages/mui-codemod/src/deprecations/all/postcss.config.js index 889555e7773c33..a37e524e2072dc 100644 --- a/packages/mui-codemod/src/deprecations/all/postcss.config.js +++ b/packages/mui-codemod/src/deprecations/all/postcss.config.js @@ -15,6 +15,7 @@ const { const { plugin: circularProgressClassesPlugin, } = require('../circular-progress-classes/postcss-plugin'); +const { plugin: tabClassesPlugin } = require('../tab-classes/postcss-plugin'); const { plugin: tableSortLabelClassesPlugin, } = require('../table-sort-label-classes/postcss-plugin'); @@ -30,6 +31,7 @@ module.exports = { paginationItemClassesPlugin, stepConnectorClassesPlugin, toggleButtonGroupClassesPlugin, + tabClassesPlugin, tableSortLabelClassesPlugin, ], }; diff --git a/packages/mui-codemod/src/deprecations/tab-classes/index.js b/packages/mui-codemod/src/deprecations/tab-classes/index.js new file mode 100644 index 00000000000000..66e683cc7b7dfc --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/index.js @@ -0,0 +1 @@ +export { default } from './tab-classes'; diff --git a/packages/mui-codemod/src/deprecations/tab-classes/postcss-plugin.js b/packages/mui-codemod/src/deprecations/tab-classes/postcss-plugin.js new file mode 100644 index 00000000000000..62c138e61402fc --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/postcss-plugin.js @@ -0,0 +1,29 @@ +const classes = [ + { + deprecatedClass: ' .MuiTab-iconWrapper', + replacementSelector: ' .MuiTab-icon', + }, +]; + +const plugin = () => { + return { + postcssPlugin: `Replace deprecated Tab classes with new classes`, + Rule(rule) { + const { selector } = rule; + + classes.forEach(({ deprecatedClass, replacementSelector }) => { + const selectorRegex = new RegExp(`${deprecatedClass}$`); + + if (selector.match(selectorRegex)) { + rule.selector = selector.replace(selectorRegex, replacementSelector); + } + }); + }, + }; +}; +plugin.postcss = true; + +module.exports = { + plugin, + classes, +}; diff --git a/packages/mui-codemod/src/deprecations/tab-classes/postcss.config.js b/packages/mui-codemod/src/deprecations/tab-classes/postcss.config.js new file mode 100644 index 00000000000000..23bebc1125be6e --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/postcss.config.js @@ -0,0 +1,5 @@ +const { plugin } = require('./postcss-plugin'); + +module.exports = { + plugins: [plugin], +}; diff --git a/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.js b/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.js new file mode 100644 index 00000000000000..94f0e9d9179660 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.js @@ -0,0 +1,80 @@ +import { classes } from './postcss-plugin'; + +/** + * @param {import('jscodeshift').FileInfo} file + * @param {import('jscodeshift').API} api + */ +export default function transformer(file, api, options) { + const j = api.jscodeshift; + const root = j(file.source); + const printOptions = options.printOptions; + classes.forEach(({ deprecatedClass, replacementSelector }) => { + const replacementSelectorPrefix = '&'; + root + .find(j.ImportDeclaration) + .filter((path) => path.node.source.value.match(/^@mui\/material\/Tab$/)) + .forEach((path) => { + path.node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'tabClasses') { + const deprecatedAtomicClass = deprecatedClass.replace( + `${deprecatedClass.split('-')[0]}-`, + '', + ); + root + .find(j.MemberExpression, { + object: { name: specifier.local.name }, + property: { name: deprecatedAtomicClass }, + }) + .forEach((memberExpression) => { + const parent = memberExpression.parentPath.parentPath.value; + if (parent.type === j.TemplateLiteral.name) { + const memberExpressionIndex = parent.expressions.findIndex( + (expression) => expression === memberExpression.value, + ); + const precedingTemplateElement = parent.quasis[memberExpressionIndex]; + const atomicClasses = ['icon']; + + if ( + precedingTemplateElement.value.raw.endsWith( + deprecatedClass.startsWith(' ') + ? `${replacementSelectorPrefix} .` + : `${replacementSelectorPrefix}.`, + ) + ) { + const atomicClassesArgs = [ + memberExpressionIndex, + 1, + ...atomicClasses.map((atomicClass) => + j.memberExpression( + memberExpression.value.object, + j.identifier(atomicClass), + ), + ), + ]; + parent.expressions.splice(...atomicClassesArgs); + } + } + }); + } + }); + }); + + const selectorRegex = new RegExp(`${replacementSelectorPrefix}${deprecatedClass}$`); + root + .find( + j.Literal, + (literal) => typeof literal.value === 'string' && literal.value.match(selectorRegex), + ) + .forEach((path) => { + path.replace( + j.literal( + path.value.value.replace( + selectorRegex, + `${replacementSelectorPrefix}${replacementSelector}`, + ), + ), + ); + }); + }); + return root.toSource(printOptions); +} diff --git a/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.test.js b/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.test.js new file mode 100644 index 00000000000000..4d382fc08389e4 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/tab-classes.test.js @@ -0,0 +1,78 @@ +import path from 'path'; +import { expect } from 'chai'; +import postcss from 'postcss'; +import { jscodeshift } from '../../../testUtils'; +import jsTransform from './tab-classes'; +import { plugin as postcssPlugin } from './postcss-plugin'; +import readFile from '../../util/readFile'; + +function read(fileName) { + return readFile(path.join(__dirname, fileName)); +} + +const postcssProcessor = postcss([postcssPlugin]); + +describe('@mui/codemod', () => { + describe('deprecations', () => { + describe('toggle-button-group-classes', () => { + describe('js-transform', () => { + it('transforms props as needed', () => { + const actual = jsTransform( + { source: read('./test-cases/actual.js') }, + { jscodeshift }, + { printOptions: { quote: 'double', trailingComma: true } }, + ); + + const expected = read('./test-cases/expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + + it('should be idempotent', () => { + const actual = jsTransform( + { source: read('./test-cases/expected.js') }, + { jscodeshift }, + {}, + ); + + const expected = read('./test-cases/expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + }); + + describe('css-transform', () => { + it('transforms classes as needed', async () => { + const actual = await postcssProcessor.process(read('./test-cases/actual.css'), { + from: undefined, + }); + + const expected = read('./test-cases/expected.css'); + expect(actual.css).to.equal(expected, 'The transformed version should be correct'); + }); + + it('should be idempotent', async () => { + const actual = await postcssProcessor.process(read('./test-cases/expected.css'), { + from: undefined, + }); + + const expected = read('./test-cases/expected.css'); + expect(actual.css).to.equal(expected, 'The transformed version should be correct'); + }); + }); + + describe('test-cases', () => { + it('should not be the same', () => { + const actualJS = read('./test-cases/actual.js'); + const expectedJS = read('./test-cases/expected.js'); + expect(actualJS).not.to.equal(expectedJS, 'The actual and expected should be different'); + + const actualCSS = read('./test-cases/actual.css'); + const expectedCSS = read('./test-cases/expected.css'); + expect(actualCSS).not.to.equal( + expectedCSS, + 'The actual and expected should be different', + ); + }); + }); + }); + }); +}); diff --git a/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.css b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.css new file mode 100644 index 00000000000000..a4c3225a1c88fc --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.css @@ -0,0 +1,4 @@ +.MuiTab-root .MuiTab-iconWrapper { + color: red; +} + diff --git a/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.js b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.js new file mode 100644 index 00000000000000..8c03199d7123fb --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/actual.js @@ -0,0 +1,4 @@ +import { tabClasses } from '@mui/material/Tab'; + +('& .MuiTab-iconWrapper'); +`& .${tabClasses.iconWrapper}`; diff --git a/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.css b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.css new file mode 100644 index 00000000000000..ed5fd401b3661e --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.css @@ -0,0 +1,3 @@ +.MuiTab-root .MuiTab-icon { + color: red; +} diff --git a/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.js b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.js new file mode 100644 index 00000000000000..f51e4d0cadafe4 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/tab-classes/test-cases/expected.js @@ -0,0 +1,4 @@ +import { tabClasses } from '@mui/material/Tab'; + +("& .MuiTab-icon"); +`& .${tabClasses.icon}`; diff --git a/packages/mui-material/src/Tab/Tab.js b/packages/mui-material/src/Tab/Tab.js index d66d492fe4775f..9a466ebf92d76c 100644 --- a/packages/mui-material/src/Tab/Tab.js +++ b/packages/mui-material/src/Tab/Tab.js @@ -24,7 +24,7 @@ const useUtilityClasses = (ownerState) => { selected && 'selected', disabled && 'disabled', ], - iconWrapper: ['iconWrapper'], + icon: ['iconWrapper', 'icon'], }; return composeClasses(slots, getTabUtilityClass, classes); @@ -45,6 +45,9 @@ const TabRoot = styled(ButtonBase, { { [`& .${tabClasses.iconWrapper}`]: styles.iconWrapper, }, + { + [`& .${tabClasses.icon}`]: styles.icon, + }, ]; }, })(({ theme }) => ({ @@ -89,7 +92,7 @@ const TabRoot = styled(ButtonBase, { props: ({ ownerState, iconPosition }) => ownerState.icon && ownerState.label && iconPosition === 'top', style: { - [`& > .${tabClasses.iconWrapper}`]: { + [`& > .${tabClasses.icon}`]: { marginBottom: 6, }, }, @@ -98,7 +101,7 @@ const TabRoot = styled(ButtonBase, { props: ({ ownerState, iconPosition }) => ownerState.icon && ownerState.label && iconPosition === 'bottom', style: { - [`& > .${tabClasses.iconWrapper}`]: { + [`& > .${tabClasses.icon}`]: { marginTop: 6, }, }, @@ -107,7 +110,7 @@ const TabRoot = styled(ButtonBase, { props: ({ ownerState, iconPosition }) => ownerState.icon && ownerState.label && iconPosition === 'start', style: { - [`& > .${tabClasses.iconWrapper}`]: { + [`& > .${tabClasses.icon}`]: { marginRight: theme.spacing(1), }, }, @@ -116,7 +119,7 @@ const TabRoot = styled(ButtonBase, { props: ({ ownerState, iconPosition }) => ownerState.icon && ownerState.label && iconPosition === 'end', style: { - [`& > .${tabClasses.iconWrapper}`]: { + [`& > .${tabClasses.icon}`]: { marginLeft: theme.spacing(1), }, }, @@ -226,7 +229,7 @@ const Tab = React.forwardRef(function Tab(inProps, ref) { const icon = iconProp && label && React.isValidElement(iconProp) ? React.cloneElement(iconProp, { - className: clsx(classes.iconWrapper, iconProp.props.className), + className: clsx(classes.icon, iconProp.props.className), }) : iconProp; const handleClick = (event) => { diff --git a/packages/mui-material/src/Tab/Tab.test.js b/packages/mui-material/src/Tab/Tab.test.js index 7da6c15552efc3..d050d49b07637e 100644 --- a/packages/mui-material/src/Tab/Tab.test.js +++ b/packages/mui-material/src/Tab/Tab.test.js @@ -127,6 +127,7 @@ describe('', () => { const { getByRole } = render(} label="foo" />); const wrapper = getByRole('tab').children[0]; expect(wrapper).to.have.class(classes.iconWrapper); + expect(wrapper).to.have.class(classes.icon); expect(wrapper).to.have.class('test-icon'); }); @@ -196,4 +197,67 @@ describe('', () => { backgroundColor: 'rgb(0, 0, 255)', }); }); + + it('should apply icon styles from theme', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const theme = createTheme({ + components: { + MuiTab: { + styleOverrides: { + icon: { + backgroundColor: 'rgb(0, 0, 255)', + }, + }, + }, + }, + }); + + const { getByRole } = render( + + hello} label="icon" /> + , + ); + const icon = getByRole('tab').querySelector(`.${classes.icon}`); + expect(icon).toHaveComputedStyle({ + backgroundColor: 'rgb(0, 0, 255)', + }); + }); + + it('icon styles should override iconWrapper styles from theme', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const theme = createTheme({ + components: { + MuiTab: { + styleOverrides: { + iconWrapper: { + backgroundColor: 'rgb(255, 0, 0)', + }, + icon: { + backgroundColor: 'rgb(0, 0, 255)', + }, + }, + }, + }, + }); + + const { getByRole } = render( + + hello} label="icon" /> + , + ); + const icon = getByRole('tab').querySelector(`.${classes.icon}`); + const iconWrapper = getByRole('tab').querySelector(`.${classes.iconWrapper}`); + expect(iconWrapper).toHaveComputedStyle({ + backgroundColor: 'rgb(0, 0, 255)', + }); + expect(icon).toHaveComputedStyle({ + backgroundColor: 'rgb(0, 0, 255)', + }); + }); }); diff --git a/packages/mui-material/src/Tab/tabClasses.ts b/packages/mui-material/src/Tab/tabClasses.ts index a1cffb8022567d..566f9d93b88fcb 100644 --- a/packages/mui-material/src/Tab/tabClasses.ts +++ b/packages/mui-material/src/Tab/tabClasses.ts @@ -20,8 +20,12 @@ export interface TabClasses { fullWidth: string; /** Styles applied to the root element if `wrapped={true}`. */ wrapped: string; - /** Styles applied to the `icon` HTML element if both `icon` and `label` are provided. */ + /** Styles applied to the `icon` HTML element if both `icon` and `label` are provided. + * @deprecated Use `icon` class instead. See [Migrating from deprecated APIs](/material-ui/migration/migrating-from-deprecated-apis/) for more details + */ iconWrapper: string; + /** Styles applied to the `icon` HTML element if both `icon` and `label` are provided. */ + icon: string; } export type TabClassKey = keyof TabClasses; @@ -41,6 +45,7 @@ const tabClasses: TabClasses = generateUtilityClasses('MuiTab', [ 'fullWidth', 'wrapped', 'iconWrapper', + 'icon', ]); export default tabClasses;