Skip to content
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

[system] Support variants in theme.styleOverrides #40690

Merged
merged 16 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions packages/mui-material/src/styles/createTheme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@ const theme = createTheme();
},
},
],
styleOverrides: {
root: {
variants: [
{
props: { variant: 'contained' },
style: {
backdropFilter: 'none',
},
},
],
},
endIcon: ({ theme: t }) => ({
backgroundColor: t.vars.palette.primary.main,
variants: [
{
props: ({ ownerState }) => ownerState.color === 'primary',
},
],
}),
},
},
},
});
Expand Down
22 changes: 13 additions & 9 deletions packages/mui-material/src/styles/overrides.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CSSObject, CSSInterpolation } from '@mui/system';
import { CSSObject, CSSInterpolation, Interpolation } from '@mui/system';
import { PopperClassKey } from '@mui/base/Popper';
import { ComponentsPropsList } from './props';
import { AccordionActionsClassKey } from '../AccordionActions';
Expand Down Expand Up @@ -122,14 +122,18 @@ export type OverridesStyleRules<
Theme = unknown,
> = Record<
ClassKey,
| CSSInterpolation
| ((
// Record<string, unknown> is for other props that the slot receive internally
// Documenting all ownerStates could be a huge work, let's wait until we have a real needs from developers.
props: (ComponentName extends keyof ComponentsPropsList
? { ownerState: ComponentsPropsList[ComponentName] & Record<string, unknown> }
: {}) & { theme: Theme } & Record<string, unknown>,
) => CSSInterpolation)
Interpolation<
// Record<string, unknown> is for other props that the slot receive internally
// Documenting all ownerStates could be a huge work, let's wait until we have a real needs from developers.
(ComponentName extends keyof ComponentsPropsList
? ComponentsPropsList[ComponentName] &
Record<string, unknown> & {
ownerState: ComponentsPropsList[ComponentName] & Record<string, unknown>;
}
: {}) & {
theme: Theme;
} & Record<string, unknown>
>
>;

export type ComponentsOverrides<Theme = unknown> = {
Expand Down
5 changes: 4 additions & 1 deletion packages/mui-styled-engine-sc/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export interface CSSObject
Omit<CSSOthersObject, 'variants'> {}

interface CSSObjectWithVariants<Props> extends Omit<CSSObject, 'variants'> {
variants: Array<{ props: Props; style: CSSObject }>;
variants: Array<{
props: Props | ((props: Props) => boolean);
style: CSSObject;
}>;
}

export type FalseyValue = undefined | null | false;
Expand Down
5 changes: 4 additions & 1 deletion packages/mui-styled-engine/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export interface CSSObject
Omit<CSSOthersObject, 'variants'> {}

interface CSSObjectWithVariants<Props> extends Omit<CSSObject, 'variants'> {
variants: Array<{ props: Props; style: CSSObject }>;
variants: Array<{
props: Props | ((props: Props) => boolean);
style: CSSObject;
}>;
}

export interface ComponentSelector {
Expand Down
261 changes: 73 additions & 188 deletions packages/mui-system/src/createStyled.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
/* eslint-disable no-underscore-dangle */
import styledEngineStyled, { internal_processStyles as processStyles } from '@mui/styled-engine';
import {
getDisplayName,
unstable_capitalize as capitalize,
isPlainObject,
deepmerge,
} from '@mui/utils';
import { getDisplayName, unstable_capitalize as capitalize, isPlainObject } from '@mui/utils';
import createTheme from './createTheme';
import propsToClassKey from './propsToClassKey';
import styleFunctionSx from './styleFunctionSx';

function isEmpty(obj) {
Expand All @@ -25,82 +19,6 @@ function isStringTag(tag) {
);
}

const getStyleOverrides = (name, theme) => {
if (theme.components && theme.components[name] && theme.components[name].styleOverrides) {
return theme.components[name].styleOverrides;
}

return null;
};

const transformVariants = (variants) => {
let numOfCallbacks = 0;
const variantsStyles = {};

if (variants) {
variants.forEach((definition) => {
let key = '';
if (typeof definition.props === 'function') {
key = `callback${numOfCallbacks}`;
numOfCallbacks += 1;
} else {
key = propsToClassKey(definition.props);
}
variantsStyles[key] = definition.style;
});
}

return variantsStyles;
};
const getVariantStyles = (name, theme) => {
let variants = [];
if (theme && theme.components && theme.components[name] && theme.components[name].variants) {
variants = theme.components[name].variants;
}

return transformVariants(variants);
};

const variantsResolver = (props, styles, variants) => {
const { ownerState = {} } = props;
const variantsStyles = [];
let numOfCallbacks = 0;

if (variants) {
variants.forEach((variant) => {
let isMatch = true;
if (typeof variant.props === 'function') {
const propsToCheck = { ...props, ...ownerState };
isMatch = variant.props(propsToCheck);
} else {
Object.keys(variant.props).forEach((key) => {
if (ownerState[key] !== variant.props[key] && props[key] !== variant.props[key]) {
isMatch = false;
}
});
}
if (isMatch) {
if (typeof variant.props === 'function') {
variantsStyles.push(styles[`callback${numOfCallbacks}`]);
} else {
variantsStyles.push(styles[propsToClassKey(variant.props)]);
}
}

if (typeof variant.props === 'function') {
numOfCallbacks += 1;
}
});
}

return variantsStyles;
};

const themeVariantsResolver = (props, styles, theme, name) => {
const themeVariants = theme?.components?.[name]?.variants;
return variantsResolver(props, styles, themeVariants);
};

// Update /system/styled/#api in case if this changes
export function shouldForwardProp(prop) {
return prop !== 'ownerState' && prop !== 'theme' && prop !== 'sx' && prop !== 'as';
Expand All @@ -126,29 +44,49 @@ function defaultOverridesResolver(slot) {
return (props, styles) => styles[slot];
}

const muiStyledFunctionResolver = ({ styledArg, props, defaultTheme, themeId }) => {
const resolvedStyles = styledArg({
...props,
theme: resolveTheme({ ...props, defaultTheme, themeId }),
});
function processStyleArg(callableStyle, { ownerState, ...props }) {
const resolvedStylesArg =
typeof callableStyle === 'function' ? callableStyle({ ownerState, ...props }) : callableStyle;

let optionalVariants;
if (resolvedStyles && resolvedStyles.variants) {
optionalVariants = resolvedStyles.variants;
delete resolvedStyles.variants;
}
if (optionalVariants) {
const variantsStyles = variantsResolver(
props,
transformVariants(optionalVariants),
optionalVariants,
if (Array.isArray(resolvedStylesArg)) {
return resolvedStylesArg.flatMap((resolvedStyle) =>
processStyleArg(resolvedStyle, { ownerState, ...props }),
);

return [resolvedStyles, ...variantsStyles];
}

return resolvedStyles;
};
if (
!!resolvedStylesArg &&
typeof resolvedStylesArg === 'object' &&
Array.isArray(resolvedStylesArg.variants)
) {
const { variants = [], ...otherStyles } = resolvedStylesArg;
let result = otherStyles;
variants.forEach((variant) => {
let isMatch = true;
if (typeof variant.props === 'function') {
isMatch = variant.props({ ownerState, ...props });
} else {
Object.keys(variant.props).forEach((key) => {
if (ownerState?.[key] !== variant.props[key] && props[key] !== variant.props[key]) {
isMatch = false;
}
});
}
if (isMatch) {
if (!Array.isArray(result)) {
result = [result];
}
result.push(
typeof variant.style === 'function'
? variant.style({ ownerState, ...props })
: variant.style,
);
}
});
return result;
}
return resolvedStylesArg;
}

export default function createStyled(input = {}) {
const {
Expand Down Expand Up @@ -217,105 +155,52 @@ export default function createStyled(input = {}) {
label,
...options,
});
const muiStyledResolver = (styleArg, ...expressions) => {
const expressionsWithDefaultTheme = expressions
? expressions.map((stylesArg) => {
// On the server Emotion doesn't use React.forwardRef for creating components, so the created
// component stays as a function. This condition makes sure that we do not interpolate functions
// which are basically components used as a selectors.
if (typeof stylesArg === 'function' && stylesArg.__emotion_real !== stylesArg) {
return (props) =>
muiStyledFunctionResolver({ styledArg: stylesArg, props, defaultTheme, themeId });
}
if (isPlainObject(stylesArg)) {
let transformedStylesArg = stylesArg;
let styledArgVariants;

if (stylesArg && stylesArg.variants) {
styledArgVariants = stylesArg.variants;
delete transformedStylesArg.variants;

transformedStylesArg = (props) => {
let result = stylesArg;
const variantStyles = variantsResolver(
props,
transformVariants(styledArgVariants),
styledArgVariants,
);
variantStyles.forEach((variantStyle) => {
result = deepmerge(result, variantStyle);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed this deepmerge. I think we should remove it and let emotion/styled-components handle it. Both of them already support array of styles.

@mnajdova Should we try to remove it? the perf should be a bit better while producing the same result.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, would it work if we remove it? From what I remember some of these definitions are merged into one style arg, this is what I remember.

Copy link
Member Author

@siriwatknp siriwatknp Feb 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would work but there is a tiny difference in the dev tool

without merging style, you will see all the styles declared in the variants when props are matched:

.hashed-variant2:hover {
  color: red;
}

.hashed-variant1:hover {
  color: green;
}

but the results (cascading) are the same.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd not merge so that the logic does not depend on the deepmerge but rely on the style engine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we tried removing the deepmerge and see if some test are failing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnajdova All green, see the change in this 473e62d

});

return result;
};
}
return transformedStylesArg;
}
return stylesArg;
})
: [];

let transformedStyleArg = styleArg;

if (isPlainObject(styleArg)) {
let styledArgVariants;
if (styleArg && styleArg.variants) {
styledArgVariants = styleArg.variants;
delete transformedStyleArg.variants;

transformedStyleArg = (props) => {
let result = styleArg;
const variantStyles = variantsResolver(
props,
transformVariants(styledArgVariants),
styledArgVariants,
);
variantStyles.forEach((variantStyle) => {
result = deepmerge(result, variantStyle);
});

return result;
};
}
} else if (
typeof styleArg === 'function' &&
// On the server Emotion doesn't use React.forwardRef for creating components, so the created
// component stays as a function. This condition makes sure that we do not interpolate functions
// which are basically components used as a selectors.
styleArg.__emotion_real !== styleArg

const transformStyleArg = (stylesArg) => {
// On the server Emotion doesn't use React.forwardRef for creating components, so the created
// component stays as a function. This condition makes sure that we do not interpolate functions
// which are basically components used as a selectors.
if (
(typeof stylesArg === 'function' && stylesArg.__emotion_real !== stylesArg) ||
isPlainObject(stylesArg)
) {
// If the type is function, we need to define the default theme.
transformedStyleArg = (props) =>
muiStyledFunctionResolver({ styledArg: styleArg, props, defaultTheme, themeId });
return (props) =>
processStyleArg(stylesArg, {
...props,
theme: resolveTheme({ theme: props.theme, defaultTheme, themeId }),
});
}
return stylesArg;
};
const muiStyledResolver = (styleArg, ...expressions) => {
let transformedStyleArg = transformStyleArg(styleArg);
const expressionsWithDefaultTheme = expressions ? expressions.map(transformStyleArg) : [];

if (componentName && overridesResolver) {
expressionsWithDefaultTheme.push((props) => {
const theme = resolveTheme({ ...props, defaultTheme, themeId });
const styleOverrides = getStyleOverrides(componentName, theme);
Copy link
Member Author

@siriwatknp siriwatknp Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStyleOverrides is not reused, so remove it to not shift between functions.


if (styleOverrides) {
const resolvedStyleOverrides = {};
Object.entries(styleOverrides).forEach(([slotKey, slotStyle]) => {
resolvedStyleOverrides[slotKey] =
typeof slotStyle === 'function' ? slotStyle({ ...props, theme }) : slotStyle;
});
return overridesResolver(props, resolvedStyleOverrides);
if (
!theme.components ||
!theme.components[componentName] ||
!theme.components[componentName].styleOverrides
) {
return null;
}

return null;
const styleOverrides = theme.components[componentName].styleOverrides;
const resolvedStyleOverrides = {};
// TODO: v7 remove iteration and use `resolveStyleArg(styleOverrides[slot])` directly
Object.entries(styleOverrides).forEach(([slotKey, slotStyle]) => {
resolvedStyleOverrides[slotKey] = processStyleArg(slotStyle, { ...props, theme });
});
return overridesResolver(props, resolvedStyleOverrides);
});
}

if (componentName && !skipVariantsResolver) {
expressionsWithDefaultTheme.push((props) => {
const theme = resolveTheme({ ...props, defaultTheme, themeId });
return themeVariantsResolver(
props,
getVariantStyles(componentName, theme),
theme,
componentName,
);
const themeVariants = theme?.components?.[componentName]?.variants;
return processStyleArg({ variants: themeVariants }, { ...props, theme });
});
}

Expand Down
Loading
Loading