diff --git a/apps/zero-runtime-next-app/next.config.js b/apps/zero-runtime-next-app/next.config.js index 283752eae66cba..57a52d81b50b8d 100644 --- a/apps/zero-runtime-next-app/next.config.js +++ b/apps/zero-runtime-next-app/next.config.js @@ -3,7 +3,18 @@ const { withZeroPlugin } = require('@mui/zero-next-plugin'); const { experimental_extendTheme: extendTheme } = require('@mui/material/styles'); -const theme = extendTheme({ cssVarPrefix: 'app' }); +const theme = extendTheme({ + cssVarPrefix: 'app', + components: { + MuiBadge: { + defaultProps: { + color: 'error', + }, + }, + }, +}); +theme.getColorSchemeSelector = (targetColorScheme) => + `[data-mui-color-scheme="${targetColorScheme}"] &`; /** * @typedef {import('@mui/zero-next-plugin').ZeroPluginConfig} ZeroPluginConfig diff --git a/apps/zero-runtime-next-app/src/app/page.tsx b/apps/zero-runtime-next-app/src/app/page.tsx index bba7f57f7f6074..377dca936779c3 100644 --- a/apps/zero-runtime-next-app/src/app/page.tsx +++ b/apps/zero-runtime-next-app/src/app/page.tsx @@ -1,5 +1,6 @@ import Image from 'next/image'; import { styled } from '@mui/zero-runtime'; +import Badge from '@mui/material/Badge'; import styles from './page.module.css'; const Main = styled.main({ @@ -77,6 +78,9 @@ const Description = styled.div({ export default function Home() { return (
+ +
Hey
+

Get started by editing  diff --git a/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.js b/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.js index 70aabd06e4d7f8..db51e2b85a4023 100644 --- a/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.js +++ b/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.js @@ -68,6 +68,15 @@ const rule = { return { CallExpression(node) { + const isCreateUseThemePropsCall = node.callee.name === 'createUseThemeProps'; + if (isCreateUseThemePropsCall) { + if (!node.arguments.length) { + context.report({ node, messageId: 'noNameValue' }); + } else if (node.arguments[0].type !== 'Literal' || !node.arguments[0].value) { + context.report({ node: node.arguments[0], messageId: 'noNameValue' }); + } + } + let nameLiteral = null; const isUseThemePropsCall = node.callee.name === 'useThemeProps'; if (isUseThemePropsCall) { diff --git a/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.test.js b/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.test.js index 34f55378923bed..b151a90806a1f4 100644 --- a/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.test.js +++ b/packages/eslint-plugin-material-ui/src/rules/mui-name-matches-component-name.test.js @@ -45,6 +45,9 @@ ruleTester.run('mui-name-matches-component-name', rule, { useThemeProps: (inProps) => useThemeProps({ props: inProps, name: 'MuiGrid2' }), }) as OverridableComponent; `, + ` + const useThemeProps = createUseThemeProps('MuiBadge'); + `, { code: ` const StaticDateRangePicker = React.forwardRef(function StaticDateRangePicker( @@ -142,5 +145,37 @@ ruleTester.run('mui-name-matches-component-name', rule, { }, ], }, + { + code: ` + const useThemeProps = createUseThemeProps(); + + const Badge = React.forwardRef(function Badge(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'MuiBadge' }); + }); + `, + errors: [ + { + message: + 'Unable to resolve `name`. Please hardcode the `name` i.e. use a string literal.', + type: 'CallExpression', + }, + ], + }, + { + code: ` + const useThemeProps = createUseThemeProps({ name: 'MuiBadge' }); + + const Badge = React.forwardRef(function Badge(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'MuiBadge' }); + }); + `, + errors: [ + { + message: + 'Unable to resolve `name`. Please hardcode the `name` i.e. use a string literal.', + type: 'ObjectExpression', + }, + ], + }, ], }); diff --git a/packages/mui-material/src/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index bcb554b0b7f65d..0acd4237363ddf 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -6,14 +6,15 @@ import { usePreviousProps } from '@mui/utils'; import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses'; import { useBadge } from '@mui/base/useBadge'; import { useSlotProps } from '@mui/base'; -import { styled } from '../zero-styled'; -import useThemeProps from '../styles/useThemeProps'; +import { styled, createUseThemeProps } from '../zero-styled'; import capitalize from '../utils/capitalize'; import badgeClasses, { getBadgeUtilityClass } from './badgeClasses'; const RADIUS_STANDARD = 10; const RADIUS_DOT = 4; +const useThemeProps = createUseThemeProps('MuiBadge'); + const useUtilityClasses = (ownerState) => { const { color, anchorOrigin, invisible, overlap, variant, classes = {} } = ownerState; diff --git a/packages/mui-material/src/zero-styled/index.d.ts b/packages/mui-material/src/zero-styled/index.d.ts deleted file mode 100644 index 1fe160d7d76817..00000000000000 --- a/packages/mui-material/src/zero-styled/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export { default as styled } from '../styles/styled'; diff --git a/packages/mui-material/src/zero-styled/index.js b/packages/mui-material/src/zero-styled/index.js deleted file mode 100644 index 1fe160d7d76817..00000000000000 --- a/packages/mui-material/src/zero-styled/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export { default as styled } from '../styles/styled'; diff --git a/packages/mui-material/src/zero-styled/index.ts b/packages/mui-material/src/zero-styled/index.ts new file mode 100644 index 00000000000000..fc74b033f7d4a6 --- /dev/null +++ b/packages/mui-material/src/zero-styled/index.ts @@ -0,0 +1,8 @@ +import useThemeProps from '../styles/useThemeProps'; + +export { default as styled } from '../styles/styled'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function createUseThemeProps(name: string) { + return useThemeProps; +} diff --git a/packages/zero-runtime/exports/createUseThemeProps.js b/packages/zero-runtime/exports/createUseThemeProps.js new file mode 100644 index 00000000000000..2d5e6f3f8bd60d --- /dev/null +++ b/packages/zero-runtime/exports/createUseThemeProps.js @@ -0,0 +1,5 @@ +Object.defineProperty(exports, '__esModule', { + value: true, +}); + +exports.default = require('../processors/createUseThemeProps').CreateUseThemePropsProcessor; diff --git a/packages/zero-runtime/package.json b/packages/zero-runtime/package.json index 07f9245d9de011..8cd0476ddf4a8e 100644 --- a/packages/zero-runtime/package.json +++ b/packages/zero-runtime/package.json @@ -58,7 +58,8 @@ "sx": "./exports/sx.js", "keyframes": "./exports/keyframes.js", "generateAtomics": "./exports/generateAtomics.js", - "css": "./exports/css.js" + "css": "./exports/css.js", + "createUseThemeProps": "./exports/createUseThemeProps.js" } }, "files": [ @@ -105,6 +106,9 @@ }, "./exports/sx": { "default": "./exports/sx.js" + }, + "./exports/createUseThemeProps": { + "default": "./exports/createUseThemeProps.js" } } } diff --git a/packages/zero-runtime/src/createUseThemeProps.d.ts b/packages/zero-runtime/src/createUseThemeProps.d.ts new file mode 100644 index 00000000000000..7fc1bbc9ea4f37 --- /dev/null +++ b/packages/zero-runtime/src/createUseThemeProps.d.ts @@ -0,0 +1,5 @@ +interface UseThemeProps { + (params: { theme: Record; props: Props; name: string }): Props; +} + +export default function createUseThemeProps(theme: any): UseThemeProps; diff --git a/packages/zero-runtime/src/createUseThemeProps.js b/packages/zero-runtime/src/createUseThemeProps.js new file mode 100644 index 00000000000000..da5edcd844ca8a --- /dev/null +++ b/packages/zero-runtime/src/createUseThemeProps.js @@ -0,0 +1,52 @@ +import { internal_resolveProps as resolveProps } from '@mui/utils'; + +/** + * Runtime function for creating `useThemeProps`. + * In the codebase, the first argument will be a string that represent the component slug (should match one of the `theme.components.*`). + * Then, the transformation will replace the first argument with the `defaultProps` object if provided. + */ +export default function createUseThemeProps(nameOrDefaultProps) { + return function useThemeProps({ props }) { + if (typeof nameOrDefaultProps === 'string') { + // if no default props provided in the theme, return the props as is. + return props; + } + const defaultProps = nameOrDefaultProps; + // The same logic as in packages/mui-utils/src/resolveProps.ts + // TODO: consider reusing the logic from the utils package + const output = { ...props }; + + Object.keys(defaultProps).forEach((propName) => { + if (propName.toString().match(/^(components|slots)$/)) { + output[propName] = { + ...defaultProps[propName], + ...output[propName], + }; + } else if (propName.toString().match(/^(componentsProps|slotProps)$/)) { + const defaultSlotProps = defaultProps[propName] || {}; + const slotProps = props[propName]; + output[propName] = {}; + + if (!slotProps || !Object.keys(slotProps)) { + // Reduce the iteration if the slot props is empty + output[propName] = defaultSlotProps; + } else if (!defaultSlotProps || !Object.keys(defaultSlotProps)) { + // Reduce the iteration if the default slot props is empty + output[propName] = slotProps; + } else { + output[propName] = { ...slotProps }; + Object.keys(defaultSlotProps).forEach((slotPropName) => { + output[propName][slotPropName] = resolveProps( + defaultSlotProps[slotPropName], + slotProps[slotPropName], + ); + }); + } + } else if (output[propName] === undefined) { + output[propName] = defaultProps[propName]; + } + }); + + return output; + }; +} diff --git a/packages/zero-runtime/src/index.ts b/packages/zero-runtime/src/index.ts index ac8805ce428e8f..52c2a5e4bc993f 100644 --- a/packages/zero-runtime/src/index.ts +++ b/packages/zero-runtime/src/index.ts @@ -3,3 +3,4 @@ export { default as sx } from './sx'; export { default as keyframes } from './keyframes'; export { generateAtomics, atomics } from './generateAtomics'; export { default as css } from './css'; +export { default as createUseThemeProps } from './createUseThemeProps'; diff --git a/packages/zero-runtime/src/processors/createUseThemeProps.ts b/packages/zero-runtime/src/processors/createUseThemeProps.ts new file mode 100644 index 00000000000000..381285222dd683 --- /dev/null +++ b/packages/zero-runtime/src/processors/createUseThemeProps.ts @@ -0,0 +1,68 @@ +import { validateParams, IOptions as IBaseOptions } from '@linaria/tags'; +import type { Expression, Params, TailProcessorParams } from '@linaria/tags'; +import BaseProcessor from './base-processor'; +import { valueToLiteral } from '../utils/valueToLiteral'; + +type IOptions = IBaseOptions & { + themeArgs: { + theme: { components?: Record }> }; + }; +}; + +export class CreateUseThemePropsProcessor extends BaseProcessor { + componentName: string; + + constructor(params: Params, ...args: TailProcessorParams) { + super(params, ...args); + if (params.length > 2) { + // no need to do any processing if it is an already transformed call or just a reference. + throw BaseProcessor.SKIP; + } + validateParams(params, ['callee', 'call'], `Invalid use of ${this.tagSource.imported} tag.`); + const [, callParam] = params; + const [, callArg] = callParam; + if (!callArg || callArg.ex.type !== 'StringLiteral') { + throw new Error( + `Invalid usage of \`createUseThemeProps\` tag, expected one string literal argument but got ${callArg?.ex.type}.`, + ); + } + this.componentName = callArg.ex.value; + } + + // eslint-disable-next-line class-methods-use-this + build(): void {} + + doEvaltimeReplacement(): void { + this.replacer(this.value, false); + } + + get value(): Expression { + return this.astService.nullLiteral(); + } + + doRuntimeReplacement(): void { + const t = this.astService; + + const { themeArgs: { theme } = {} } = this.options as IOptions; + if (!theme?.components?.[this.componentName]?.defaultProps) { + return; + } + + const useThemePropsImportIdentifier = t.addNamedImport( + this.tagSource.imported, + process.env.PACKAGE_NAME as string, + ); + + this.replacer( + t.callExpression(useThemePropsImportIdentifier, [ + valueToLiteral(theme.components[this.componentName].defaultProps), + ]), + true, + ); + } + + public override get asSelector(): string { + // For completeness, this is not intended to be used. + return `.${this.className}`; + } +} diff --git a/packages/zero-runtime/src/processors/styled.ts b/packages/zero-runtime/src/processors/styled.ts index a19841510de232..b59df0c9c8a6d4 100644 --- a/packages/zero-runtime/src/processors/styled.ts +++ b/packages/zero-runtime/src/processors/styled.ts @@ -40,8 +40,6 @@ type ComponentMeta = { skipSx?: boolean; }; -type DefaultProps = Record; - /** * Linaria tag processor responsible for converting complex `styled()()` calls * at build-time to simple `styled` calls supported by runtime. @@ -115,8 +113,6 @@ export class StyledProcessor extends BaseProcessor { originalLocation: SourceLocation | null = null; - defaultProps: DefaultProps = {}; - constructor(params: Params, ...args: TailProcessorParams) { super(params, ...args); if (params.length <= 2) { @@ -333,11 +329,6 @@ export class StyledProcessor extends BaseProcessor { componentMetaExpression = parsedMeta as ObjectExpression; } } - if (this.defaultProps && Object.keys(this.defaultProps).length > 0) { - argProperties.push( - t.objectProperty(t.identifier('defaultProps'), valueToLiteral(this.defaultProps)), - ); - } const styledImportIdentifier = t.addNamedImport( this.tagSource.imported, @@ -419,9 +410,6 @@ export class StyledProcessor extends BaseProcessor { if ('variants' in componentData && componentData.variants) { variantsAccumulator.push(...(componentData.variants as unknown as VariantData[])); } - if ('defaultProps' in componentData && componentData.defaultProps) { - this.defaultProps = componentData.defaultProps as DefaultProps; - } } /** diff --git a/packages/zero-runtime/src/styled.jsx b/packages/zero-runtime/src/styled.jsx index c91633690ee652..c9c88918c2e73f 100644 --- a/packages/zero-runtime/src/styled.jsx +++ b/packages/zero-runtime/src/styled.jsx @@ -40,12 +40,7 @@ function defaultShouldForwardProp(propKey) { * @param {Object} componentMeta.defaultProps Default props object copied over and inlined from theme object */ export default function styled(tag, componentMeta = {}) { - const { - name, - slot, - defaultProps = {}, - shouldForwardProp = defaultShouldForwardProp, - } = componentMeta; + const { name, slot, shouldForwardProp = defaultShouldForwardProp } = componentMeta; /** * @TODO - Filter props and only pass necessary props to children * @@ -64,7 +59,6 @@ export default function styled(tag, componentMeta = {}) { * @param {string} options.name * @param {string} options.slot * @param {ShouldForwardProp} options.shouldForwardProp - * @param {Object} options.defaultProps Default props object copied over and inlined from theme object */ function scopedStyledWithOptions(options = {}) { const { displayName, classes = [], vars: cssVars = {}, variants = [] } = options; @@ -163,7 +157,6 @@ export default function styled(tag, componentMeta = {}) { }); StyledComponent.displayName = `Styled(${componentName})`; - StyledComponent.defaultProps = defaultProps; // eslint-disable-next-line no-underscore-dangle StyledComponent.__isStyled = true; diff --git a/packages/zero-runtime/tsup.config.ts b/packages/zero-runtime/tsup.config.ts index 346f60915c1945..635ac6fa9b2f64 100644 --- a/packages/zero-runtime/tsup.config.ts +++ b/packages/zero-runtime/tsup.config.ts @@ -2,7 +2,7 @@ import { Options, defineConfig } from 'tsup'; import config from '../../tsup.config'; import packageJson from './package.json'; -const processors = ['styled', 'sx', 'keyframes', 'generateAtomics', 'css']; +const processors = ['styled', 'sx', 'keyframes', 'generateAtomics', 'css', 'createUseThemeProps']; const external = ['react', 'react-is', 'prop-types']; const baseConfig: Options = { diff --git a/packages/zero-vite-plugin/src/zero-vite-plugin.ts b/packages/zero-vite-plugin/src/zero-vite-plugin.ts index e4b855d86126b0..bafee077d0c010 100644 --- a/packages/zero-vite-plugin/src/zero-vite-plugin.ts +++ b/packages/zero-vite-plugin/src/zero-vite-plugin.ts @@ -205,7 +205,7 @@ export default function zeroVitePlugin({ if (tagResult) { return tagResult; } - if (source.endsWith('/zero-styled')) { + if (source.endsWith('/zero-styled') || source.endsWith('/zero-useThemeProps')) { return `${process.env.RUNTIME_PACKAGE_NAME}/exports/${tag}`; } return null;