diff --git a/packages/pigment-css-react/package.json b/packages/pigment-css-react/package.json index 7e7c9c809ce883..4613a04db77e02 100644 --- a/packages/pigment-css-react/package.json +++ b/packages/pigment-css-react/package.json @@ -37,6 +37,7 @@ "@babel/parser": "^7.23.9", "@babel/types": "^7.23.9", "@emotion/css": "^11.11.2", + "@emotion/is-prop-valid": "^1.2.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/styled": "^11.11.0", diff --git a/packages/pigment-css-react/src/styled.jsx b/packages/pigment-css-react/src/styled.js similarity index 66% rename from packages/pigment-css-react/src/styled.jsx rename to packages/pigment-css-react/src/styled.js index 05e0721c79902e..0febf1e7df4512 100644 --- a/packages/pigment-css-react/src/styled.jsx +++ b/packages/pigment-css-react/src/styled.js @@ -1,5 +1,6 @@ import * as React from 'react'; import clsx from 'clsx'; +import isPropValid from '@emotion/is-prop-valid'; function getVariantClasses(componentProps, variants) { const { ownerState = {} } = componentProps; @@ -15,16 +16,21 @@ function getVariantClasses(componentProps, variants) { return variantClasses; } -/** - * @param {string} propKey - * @returns {boolean} - */ -function defaultShouldForwardProp(propKey) { - return propKey !== 'sx' && propKey !== 'as' && propKey !== 'ownerState'; +function isHtmlTag(tag) { + return ( + typeof tag === 'string' && + // 96 is one less than the char code + // for "a" so this is checking that + // it's a lowercase character + tag.charCodeAt(0) > 96 + ); } +const slotShouldForwardProp = (key) => key !== 'sx' && key !== 'as' && key !== 'ownerState'; +const rootShouldForwardProp = (key) => slotShouldForwardProp(key) && key !== 'classes'; + /** - * @typedef {typeof defaultShouldForwardProp} ShouldForwardProp + * @typedef {(propKey: string) => boolean} ShouldForwardProp */ /** @@ -40,10 +46,20 @@ 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, shouldForwardProp = defaultShouldForwardProp } = componentMeta; + const { name, slot, shouldForwardProp } = componentMeta; + + let finalShouldForwardProp = shouldForwardProp; + if (!shouldForwardProp) { + if (isHtmlTag(tag)) { + finalShouldForwardProp = isPropValid; + } else if (slot === 'Root' || slot === 'root') { + finalShouldForwardProp = rootShouldForwardProp; + } else { + finalShouldForwardProp = slotShouldForwardProp; + } + } + const shouldUseAs = !finalShouldForwardProp('as'); /** - * @TODO - Filter props and only pass necessary props to children - * * This is the runtime `styled` function that finally renders the component * after transpilation through WyW-in-JS. It makes sure to add the base classes, * variant classes if they satisfy the prop value and also adds dynamic css @@ -62,26 +78,10 @@ export default function styled(tag, componentMeta = {}) { */ function scopedStyledWithOptions(options = {}) { const { displayName, classes = [], vars: cssVars = {}, variants = [] } = options; - let componentName = 'Component'; - - if (name) { - if (slot) { - componentName = `${name}${slot}`; - } else { - componentName = name; - } - } else if (displayName) { - componentName = displayName; - } - const StyledComponent = React.forwardRef(function StyledComponent( - // eslint-disable-next-line react/prop-types - { as, className, sx, style, ...props }, - ref, - ) { - // eslint-disable-next-line react/prop-types - const { ownerState, ...restProps } = props; - const Component = as ?? tag; + const StyledComponent = React.forwardRef(function StyledComponent(inProps, ref) { + const { as, className, sx, style, ownerState, ...props } = inProps; + const Component = (shouldUseAs && as) || tag; const varStyles = Object.entries(cssVars).reduce( (acc, [cssVariable, [variableFunction, isUnitLess]]) => { const value = variableFunction(props); @@ -97,9 +97,7 @@ export default function styled(tag, componentMeta = {}) { }, {}, ); - // eslint-disable-next-line react/prop-types const sxClass = typeof sx === 'string' ? sx : sx?.className; - // eslint-disable-next-line react/prop-types const sxVars = sx && typeof sx !== 'string' ? sx.vars : undefined; if (sxVars) { @@ -112,40 +110,31 @@ export default function styled(tag, componentMeta = {}) { }); } - const finalClassName = clsx(classes, sxClass, className, getVariantClasses(props, variants)); - const toPassProps = Object.keys(restProps) - .filter((item) => { - const res = shouldForwardProp(item); - if (res) { - return defaultShouldForwardProp(item); - } - return false; - }) - .reduce((acc, key) => { - acc[key] = restProps[key]; - return acc; - }, {}); + const finalClassName = clsx( + classes, + sxClass, + className, + getVariantClasses(inProps, variants), + ); + + const newProps = {}; + // eslint-disable-next-line no-restricted-syntax + for (const key in props) { + if (shouldUseAs && key === 'as') { + continue; + } - // eslint-disable-next-line no-underscore-dangle - if (!Component.__isStyled || typeof Component === 'string') { - return ( - // eslint-disable-next-line react/jsx-filename-extension - - ); + if (finalShouldForwardProp(key)) { + newProps[key] = props[key]; + } } return ( ownerState.color === 'secondary', + style: { + color: 'salmon', + }, + }, + ], +}); diff --git a/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.css b/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.css new file mode 100644 index 00000000000000..4a6e20753801af --- /dev/null +++ b/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.css @@ -0,0 +1,6 @@ +.b1prasel-1 { + color: tomato; +} +.b1prasel-2 { + color: salmon; +} diff --git a/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.js b/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.js new file mode 100644 index 00000000000000..6b450dcd6f112a --- /dev/null +++ b/packages/pigment-css-react/tests/styled/fixtures/styled-variants.output.js @@ -0,0 +1,17 @@ +import { styled as _styled } from '@pigment-css/react'; +import _theme from '@pigment-css/react/theme'; +const Button = /*#__PURE__*/ _styled('button')({ + classes: ['b1prasel'], + variants: [ + { + props: { + color: 'primary', + }, + className: 'b1prasel-1', + }, + { + props: ({ ownerState }) => ownerState.color === 'secondary', + className: 'b1prasel-2', + }, + ], +}); diff --git a/packages/pigment-css-react/tests/styled/runtime-styled.test.js b/packages/pigment-css-react/tests/styled/runtime-styled.test.js new file mode 100644 index 00000000000000..d2f325b082b0a3 --- /dev/null +++ b/packages/pigment-css-react/tests/styled/runtime-styled.test.js @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer } from '@mui-internal/test-utils'; +import styled from '../../src/styled'; + +describe('props filtering', () => { + const { render } = createRenderer(); + + it('composes shouldForwardProp on composed styled components', () => { + const StyledDiv = styled('div', { + shouldForwardProp: (prop) => prop !== 'foo', + })(); + + const ComposedDiv = styled(StyledDiv, { + shouldForwardProp: (prop) => prop !== 'bar', + })(); + + const { container } = render(); + + expect(container.firstChild).to.not.have.attribute('foo'); + expect(container.firstChild).to.not.have.attribute('bar'); + expect(container.firstChild).to.have.attribute('xyz', 'true'); + }); + + it('custom shouldForwardProp works', () => { + function Svg(props) { + return ( + + + + ); + } + + const StyledSvg = styled(Svg, { + shouldForwardProp: (prop) => ['className', 'width', 'height'].indexOf(prop) !== -1, + })` + &, + & * { + fill: ${({ color }) => color}; + } + `; + + const { container } = render(); + expect(container.firstChild).to.not.have.attribute('color'); + expect(container.firstChild).to.have.attribute('width', '100px'); + expect(container.firstChild).to.have.attribute('height', '100px'); + }); + + it('default prop filtering for native html tag', () => { + const Link = styled('a')` + color: green; + `; + const rest = { m: [3], pt: [4] }; + + const { container } = render( + + hello world + , + ); + expect(container.firstChild).to.have.attribute('href', 'link'); + expect(container.firstChild).to.have.attribute('aria-label', 'some label'); + expect(container.firstChild).to.have.attribute('data-wow', 'value'); + expect(container.firstChild).to.have.attribute('is', 'true'); + + expect(container.firstChild).not.to.have.attribute('a'); + expect(container.firstChild).not.to.have.attribute('b'); + expect(container.firstChild).not.to.have.attribute('wow'); + expect(container.firstChild).not.to.have.attribute('prop'); + expect(container.firstChild).not.to.have.attribute('filtering'); + expect(container.firstChild).not.to.have.attribute('cool'); + }); + + it('no prop filtering on non string tags', () => { + // eslint-disable-next-line jsx-a11y/anchor-has-content + const Link = styled((props) => )` + color: green; + `; + + const { container } = render( + + hello world + , + ); + + expect(container.firstChild).to.have.attribute('href', 'link'); + expect(container.firstChild).to.have.attribute('aria-label', 'some label'); + expect(container.firstChild).to.have.attribute('data-wow', 'value'); + expect(container.firstChild).to.have.attribute('is', 'true'); + expect(container.firstChild).to.have.attribute('a', 'true'); + expect(container.firstChild).to.have.attribute('b', 'true'); + expect(container.firstChild).to.have.attribute('wow', 'true'); + expect(container.firstChild).to.have.attribute('prop', 'true'); + expect(container.firstChild).to.have.attribute('filtering', 'true'); + expect(container.firstChild).to.have.attribute('cool', 'true'); + }); + + describe('ownerState prop', () => { + it('[HTML tag] does not forward `ownerState` by default', () => { + const StyledDiv = styled('div')(); + + const { container } = render(); + expect(container.firstChild).not.to.have.attribute('ownerState'); + }); + + it('does not forward `ownerState` to other React component', () => { + function InnerComponent(props) { + const { ownerState } = props; + return
; + } + const StyledDiv = styled(InnerComponent)(); + + const { container } = render(); + expect(container.firstChild).not.to.have.attribute('ownerState'); + expect(container.firstChild).to.have.attribute('data-ownerstate', 'false'); + }); + + it('forward `ownerState` to inherited styled component', () => { + const StyledDiv = styled('div')({ + classes: ['div1'], + variants: [ + { + props: ({ ownerState }) => ownerState.color === 'secondary', + className: 'div1-secondary', + }, + ], + }); + + const StyledDiv2 = styled(StyledDiv)({ + classes: ['div2'], + variants: [ + { + props: ({ ownerState }) => ownerState.color === 'secondary', + className: 'div2-secondary', + }, + ], + }); + + const { container } = render(); + expect(container.firstChild).to.have.class('div1-secondary'); + expect(container.firstChild).to.have.class('div2-secondary'); + }); + }); + + describe('classes prop', () => { + it('does not forward `classes` by default', () => { + const StyledDiv = styled('div')(); + + const { container } = render(); + expect(container.firstChild).not.to.have.attribute('classes'); + }); + + it('does not forward `classes` for the root slot to other React component', () => { + function InnerComponent(props) { + const { classes = {} } = props; + return
; + } + const StyledDiv = styled(InnerComponent, { + name: 'Div', + slot: 'root', + })(); + + const { container } = render(); + expect(container.firstChild).not.to.have.attribute('classes'); + expect(container.firstChild).not.to.have.class('root-123'); + }); + + it('forward `classes` for the root slot by a custom shouldForwardProp', () => { + function ButtonBase(props) { + const { classes = {} } = props; + return
; + } + const ButtonRoot = styled(ButtonBase, { + name: 'Div', + slot: 'root', + shouldForwardProp: (prop) => prop === 'classes', + })(); + + const { container } = render(); + expect(container.firstChild).to.have.class('root-123'); + }); + }); +}); diff --git a/packages/pigment-css-react/tests/styled/styled.test.ts b/packages/pigment-css-react/tests/styled/styled.test.tsx similarity index 81% rename from packages/pigment-css-react/tests/styled/styled.test.ts rename to packages/pigment-css-react/tests/styled/styled.test.tsx index b061767fc27d10..4e555d7497c670 100644 --- a/packages/pigment-css-react/tests/styled/styled.test.ts +++ b/packages/pigment-css-react/tests/styled/styled.test.tsx @@ -67,4 +67,18 @@ describe('Pigment CSS - styled', () => { expect(output.js).to.equal(fixture.js); expect(output.css).to.equal(fixture.css); }); + + it('should work with variants', async () => { + const { output, fixture } = await runTransformation( + path.join(__dirname, 'fixtures/styled-variants.input.js'), + { + themeArgs: { + theme, + }, + }, + ); + + expect(output.js).to.equal(fixture.js); + expect(output.css).to.equal(fixture.css); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b3f66cce2438d..ccdc319d95bb87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2166,6 +2166,9 @@ importers: '@emotion/css': specifier: ^11.11.2 version: 11.11.2 + '@emotion/is-prop-valid': + specifier: ^1.2.2 + version: 1.2.2 '@emotion/react': specifier: ^11.11.4 version: 11.11.4(@types/react@18.2.55)(react@18.2.0) @@ -4429,6 +4432,11 @@ packages: dependencies: '@emotion/memoize': 0.8.1 + /@emotion/is-prop-valid@1.2.2: + resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} + dependencies: + '@emotion/memoize': 0.8.1 + /@emotion/memoize@0.7.1: resolution: {integrity: sha512-Qv4LTqO11jepd5Qmlp3M1YEjBumoTHcHFdgPTQ+sFlIL5myi/7xu/POwP7IRu6odBdmLXdtIs1D6TuW6kbwbbg==} dev: false @@ -4501,7 +4509,7 @@ packages: dependencies: '@babel/runtime': 7.24.1 '@emotion/babel-plugin': 11.11.0 - '@emotion/is-prop-valid': 1.2.1 + '@emotion/is-prop-valid': 1.2.2 '@emotion/react': 11.11.4(@types/react@18.2.55)(react@18.2.0) '@emotion/serialize': 1.1.3 '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) @@ -22458,6 +22466,7 @@ packages: '@babel/parser': 7.24.1 '@babel/types': 7.24.0 '@emotion/css': 11.11.2 + '@emotion/is-prop-valid': 1.2.2 '@emotion/react': 11.11.4(@types/react@18.2.55)(react@18.2.0) '@emotion/serialize': 1.1.3 '@emotion/styled': 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.55)(react@18.2.0) diff --git a/tsup.config.ts b/tsup.config.ts index 159ef80835ef07..255be9ede538e7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -30,4 +30,7 @@ export default defineConfig({ env: { PACKAGE_NAME: pkgJson.name, }, + loader: { + '.js': 'jsx', + }, });