Skip to content

Instantly share code, notes, and snippets.

@mnajdova
Last active April 25, 2022 12:44
Show Gist options
  • Save mnajdova/b0562785b7cf1b9d9aeae382d578594b to your computer and use it in GitHub Desktop.
Save mnajdova/b0562785b7cf1b9d9aeae382d578594b to your computer and use it in GitHub Desktop.
Migration to emotion

Migration to emotion

Converting styles from JSS to emotion

Things to consider when migrating component's styles to emotion.

In v4 we were defining the styles like:

export const styles = (theme) => ({
  /* Styles applied to the root element. */
  root: {},
  /* Styles applied to the root element if `container={true}`. */
  container: {
    boxSizing: 'border-box',
    display: 'flex',
    flexWrap: 'wrap',
    width: '100%',
  },
  /* Styles applied to the root element if `item={true}`. */
  item: {
    boxSizing: 'border-box',
    margin: 0, // For instance, it's useful when used with a `figure` element.
  },
  // ...
}

In v5, we are using the styled() (named experimentalStyled() for now) utility for creating the components. This means, that we no longer have keys for specifying styles for specific props or slots, so we need to convert these keys to conditional spreading based on prop values, if they are based on a prop, or to completely different styled components, if they are slots. The previous example would look something like this:

const GridRoot = experimentalStyled(
  'div',
  {},
  {
    name: 'MuiGrid',
    slot: 'Root',
    overridesResolver, // we'll explain this part later
  },
)(({ theme, styleProps}) => ({
  ...(stylesProps.container && { // apply these styles if container={true}
    boxSizing: 'border-box',
    display: 'flex',
    flexWrap: 'wrap',
    width: '100%',
  }),
  ...(stylesProps.item && { // apply these styles if item={true}
    boxSizing: 'border-box',
    margin: 0, // For instance, it's useful when used with a `figure` element.
  })
}));

If there are some slots, it would look like this:

// Button styles
const styles = (theme) => ({
  root: {
    minWidth: 64,
    padding: '6px 16px',
  },
  label: {
    display: 'flex',
  }
})
const ButtonRoot = experimentalStyled(
  'div',
  {},
  {
    name: 'MuiButton',
    slot: 'Root',
    overridesResolver, // we'll explain this part later
  },
)({
  minWidth: 64,
  padding: '6px 16px',
});

const ButtonLabel = experimentalStyled(
  'div',
  {},
  {
    name: 'MuiButton',
    slot: 'Label',
  },
)({
  display: 'flex',
});

Note that we are using the styleProps for accessing the props value. This is done for mainly two reasons:

  • having one prop that is a collection of all related styling props, helps us in terms of perf to decide more easily which props should we spread on the HTML element and which should be ignored.
  • having a separate prop for this, can help us easily extend it to contain state as well as some derivative props which are calculated based on other props.

Some "Got ya"-s

  • By defining the styles in one object, you are basically spreading all styles inside the style function result, which means that if you define the same key multiple times, it will override what you had defined before. This is usually what you want, but can be tricky when it comes to pseudo or class selector, breakpoints, etc. For example, spreading ':hover': {} somewhere along the object will replace all definition for the ':hover' styles you had before. To avoid this issue, we recommend you either spread all styles for the specific selector in one place, by moving the condition inside that definition or use multiple callbacks when defining the styles. You can see an example of the first one on the Button component and for the second one on the Container component
  • If you found throughout the styles selectors like $item, you will need to replace them with a class (usually some of the classes available on the componentClasses object we export - more on this later). For example:
-'& > $item': {
+[`& > .${gridClasses.item}`]: {
  maxWidth: 'none',
},

experimentalStyled() params

I've mentioned that we are using the experimentalStyled(), now we will see what params we need to provide for each component that we create.

The first parameter of the experimentalStyled() is the element or component that should serve as a base for the component. It can be a simple HTML element, like div, or a different component. For example, the ButtonRoot component is defined on top of the ButtonBase.

The second parameter are additional option props for the styled() utility that comes from the @material-ui/styled-engine (emotion or styled-components), like label, shouldForwardProp, target. These are set by default for you, but if you need to opt-out you can do it.

Finally, the third argument is the overrideResolver function, which based on the props and the style overrides coming from the theme, will create additional styles that will be applied on top of the default component styles. Here is one example of how the function can look like:

const overridesResolver = (props, styles) => {
  const { styleProps } = props;

  return deepmerge(styles.root || {}, {
    ...styles[styleProps.variant],
    ...styles[`${styleProps.variant}${capitalize(styleProps.color)}`],
    ...styles[`size${capitalize(styleProps.size)}`],
    ...styles[`${styleProps.variant}Size${capitalize(styleProps.size)}`],
    ...(styleProps.color === 'inherit' && styles.colorInherit),
    ...(styleProps.disableElevation && styles.disableElevation),
    ...(styleProps.fullWidth && styles.fullWidth),
    [`& .${buttonClasses.label}`]: styles.label,
    [`& .${buttonClasses.startIcon}`]: {
      ...styles.startIcon,
      ...styles[`iconSize${capitalize(styleProps.size)}`],
    },
    [`& .${buttonClasses.endIcon}`]: {
      ...styles.endIcon,
      ...styles[`iconSize${capitalize(styleProps.size)}`],
    },
  });
};

You will see here that, based on the values inside the styleProps we decide which overrides we need to apply, for example, if the styleProps.disableElevation is true, we will apply the styles.disableElevation overrides (line 10 on the example above).

For the slots, we need to use the class selector for the appropriate slot when adding the overrides, we have in the example above overrides for the label, startIcon and endIcon slots accordingly.

Utility & override classes

We expect each component to export as part of its package object containing the default utility classes as well as a helper function for generating utility classes. For creating this you should use the generateUtilityClass and generateUtilityClasses helpers from @mateiral-ui/unstyled. Here is an example of this:

import { generateUtilityClass, generateUtilityClasses } from '@material-ui/unstyled';

export function getButtonUtilityClass(slot) {
  return generateUtilityClass('MuiButton', slot);
}

const buttonClasses = generateUtilityClasses('MuiButton', [
  'root',
  'label',
  'text',
  'textInherit',
  'textPrimary',
  'textSecondary',
  'outlined',
  'outlinedInherit',
  'outlinedPrimary',
  'outlinedSecondary',
  // ...
]);

export default buttonClasses;

These utility classes are used later in the tests to ensure that the logic of the component is correct. Also, they are useful when defining the styles if any of the utility classes should be used as selectors.

In the component's logic, you need to actually add these classes to the rendered tree based on the styleProps. We do this by defining a new useUtilityClasses hook. Here is an example of it:

const useUtilityClasses = (styleProps) => {
  const { color, disableElevation, fullWidth, size, variant, classes = {} } = styleProps;

  // Define here key for each of the slots of the component
  const slots = {
    root: [
      'root',
      variant,
      `${variant}${capitalize(color)}`,
      `size${capitalize(size)}`,
      `${variant}Size${capitalize(size)}`,
      color === 'inherit' && 'colorInherit', // some classes may be added conditionally based on a prop
      disableElevation && 'disableElevation',
      fullWidth && 'fullWidth',
    ],
    label: ['label'],
    startIcon: ['startIcon', `iconSize${capitalize(size)}`],
    endIcon: ['endIcon', `iconSize${capitalize(size)}`],
  };

  // always use this utility for generating the classes for the slots
  // it will make sure that both the utility and props' classes will be applied
  return composeClasses(slots, getButtonUtilityClass, classes);
};

When you use this inside the component, make sure that you invoke it with the styleProps and make sure that those contain the props' classes, so that overrides will be applied.

Theme default props

You need to make sure you use the unstable_useThemeProps hook from @material-ui/core/styles, so that theme's default props will be merged with user-defined.

Usually each component's render method, will start with using this hook:

const Grid = React.forwardRef(function Grid(inProps, ref) {
  const props = useThemeProps({ props: inProps, name: 'MuiGrid' });
  // ...
}  

You need to make sure that you will use the correct name here, as it is used as a key in the theme's components definition.

In addition to the component's props, this hook returns also the isRtl and theme props, which can be accessed inside the rendered tree.

How to use new the ComponentRoot & other slot components

Each component that you create with the experimentalStyled() utility should be used somehow in your render tree. The ComponentRoot should be returned as the root of your rendered tree and it usually requires the as property set to the component prop if available in the core component. You need to make sure you also provide the stylesProps as well as the classes['slot'] which we generated with the useUtilityClasses hook.

TS updates

All components that are created using experimentalStyled(), have the support for the sx prop. Hence, this prop needs to be added to the components' props.

Test updates

For the test to work as expected, you need to first import the componentClasses and consider them as classes throughout all test suites, instead of generating them with getClasses().

In addition to this, you need to convert the describeConformance to describreConformanceV5. You will most likely need to add some additional options there. For this, I recommend following what is done in the Button.test.js.

There may be required some additional updates on the tests, based on how the generated styles are being tested, but this is a per-component basis.

Verify that everything works as expected

After doing the changes, it's time to test that everything works fine. You should follow the following steps:

  1. All tests should be green, you can verify this both locally or on the PR's CI
  2. There should be no argos (screenshot) differences on the PR
  3. Run locally the docs and check out the component's docs page. It should run without any errors or visual issues

Potential issues and their fixes

If yarn workspace framer build is failing, you will need to add the new sx prop in the ignoredProps for the compoennt you are migrating. For example:

diff --git a/framer/scripts/framerConfig.js b/framer/scripts/framerConfig.js
index fb02f439b6..526d4d294a 100644
--- a/framer/scripts/framerConfig.js
+++ b/framer/scripts/framerConfig.js
@@ -225,6 +225,7 @@ export const componentSettings = {
   },
   Paper: {
     ignoredProps: [
+      'sx',
       // FIXME: `Union`
       'variant',
     ],
@vicasas
Copy link

vicasas commented Jan 14, 2021

Could you add to this was it how to test the components locally with npm?

@mnajdova
Copy link
Author

Could you add to this was it how to test the components locally with npm?

@vicasas Just to clarify so you mean how to run locally and test them and how to run the test suites or something else?

if I understood correctly it’s about how to make sure the changes are working. If this is the case, then all tests should be green, there should be no argos (screenshot) differences and the doc’s component page should run without any errors or visual issues. (Let me know if this is what you meant)

@vicasas
Copy link

vicasas commented Jan 14, 2021

@mnajdova Yes, exactly that. Verify that the component works correctly visually after migration.

@mnajdova
Copy link
Author

Updated, thanks for the feedback @vicasas 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment