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

Add support for combining multiple variants #586

Closed
wants to merge 7 commits into from

Conversation

lachlanjc
Copy link
Member

Inspired by #403, I've added support for combining multiple variants by passing variants an array. It's a new prop on components, or a property inside sx.

  • Docs & tests are included for both components & css.
  • The new test on css is failing because something about my implementation in css doesn't work—to be honest, I have no idea what's wrong & would super appreciate some assistance there.
  • Targeting v0.3 because I don't want to create more work with upgrading this :)
  • Added to changelog

Copy link
Member

@jxnblk jxnblk left a comment

Choose a reason for hiding this comment

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

This is looking great so far! I think since this shouldn't introduce any breaking changes, I'd like to get this out after v0.3

I need a bit of technical help finishing the css support but it's 90% ready to go.

Based on this comment from #403, let me know if I can point you in the right direction anywhere -- at a quick glance, it looks like it's not removing the variants key from the style object, but can dig in later

packages/components/package.json Outdated Show resolved Hide resolved
packages/components/package.json Show resolved Hide resolved
packages/components/test/index.js Show resolved Hide resolved
packages/css/test/index.js Show resolved Hide resolved
@lachlanjc lachlanjc changed the base branch from next-core-mdx to master January 23, 2020 19:27
@medelman17
Copy link

medelman17 commented Jan 25, 2020

This would be amazing. But maybe even better if we could tackle this issue through a ‘styled-components-modifiers’-like ‘modifiers’ property to keep the API clean/distinct?

On one hand, having to repeatedly deep merge in your mind while developing is NOT fun. On the other, I’d imagine that 99% of the time the override-variant will be making a minor change to the styling—otherwise one would just have another ‘no-help-needed’ variant defined.

So, to me, it feels like a we need a little door to reach through (should the need arise) and make small tweaks in an otherwise manageable way. In my mind, it would look something like this:

<Text sx={{ variants: “text.h1”, modifiers: [‘bold’, ‘allCaps’, ‘primaryColor’, ‘etc.’] }} > ...

I dunno; just spit ballin’. But would be very happy to see current PR land

@lachlanjc
Copy link
Member Author

lachlanjc commented Jan 25, 2020

Sorry, could you clarify exactly what the difference you're proposing with the modifiers property is @medelman17? A bit confused how that's different.

@medelman17
Copy link

I guess, in my mind, it’s not different in the result. Which is why I think this PR is great! I just think the ergonomics are tougher when everything goes through one property. So, it’s different in execution, I guess—for my mental model, thinking of the ‘variant’ as the dominant style and the ‘modifiers’ as tweaks makes a lot of sense. I feel like I can see clearer lines of demarcation. Theming is hard; especially for beginners. I’m just throwin’ it out there.

@lachlanjc
Copy link
Member Author

Hmm, that makes a little more sense. The example I use in the docs is title + caps, where they’re not overlapping, just combining. In practice styles will definitely overlap but I think it’s kind of the same thing as adding several classes in traditional CSS—but actually simpler because you don’t have a specificity battle after that.

@CanRau
Copy link

CanRau commented Jan 26, 2020

hum I'd still vote for variants.
I think I get the idea of modifiers yet it feels kinda limiting and unnecessarily adds a new terminology 🤔

@dakebl
Copy link

dakebl commented Jan 27, 2020

hum I'd still vote for variants.
I think I get the idea of modifiers yet it feels kinda limiting and unnecessarily adds a new terminology 🤔

Yeah, I agree. I like that pluralising variant makes it simple to understand what is expected, multiple variants.

@brunnolou
Copy link

This looks cool at first glance, but I think it is conceptually wrong 😞

1. Invalid variants

For example, this is proposed here:

  buttons: {
    // This alone is a button variant
    primary: {
      color: 'white',
      bg: 'primary',
    },
    // This alone is a button variant
    secondary: {
      color: 'white',
      bg: 'secondary',
    },
    // This alone is NOT a button variant! 😞
    small: {
      fontSize: 1
    }
  },

The buttons.small alone is NOT a button variant.

Imagine in the future a design tool reading all buttons to display them, and then we have an unstyled button.small...

I agree with @medelman17 when he said:

thinking of the ‘variant’ as the dominant style and the ‘modifiers’ as tweaks

2. Array API and breakpoints

Using arrays is "kinda reserved" to breakpoints, @jxnblk mentioned here at #403 (comment)

Could be confused with the future variant={['text.body', 'text.heading']}
Having nested arrays to solve this could be even more confusing.

3. Similar but competing APIs

What to expect when combining both variant ad variants 🤷‍♂.

4. Allows breaking the design system

This very similar to class names. Meaning that everything is now possible, even invalid combinations:

sx={{ variants: ['buttons.primary', 'cards.compact'] }}`

To be honest, I don't think we should allow multiple variants on the sx prop.
If we are to do this, we might want only to allow in the theme spec to guarantee the correct later usage.

My proposal

  1. Object notation instead.
  2. Add Modifiers (the name could be something different like: variantModifiers, variantTypes, addons, or even variants).
  3. Group them inside the variants.
buttons: {
  primary: {
    color: 'white',
    bg: 'primary',
  },
  secondary: {
    color: 'white',
    bg: 'secondary',
  },
  modifiers: {
    size: {
      small: { fontSize: 1 },
    },
    shape: {
      rounded: { borderRadius: 4 },
      pill: { borderRadius: 100 },
    },
  },
},

Usage

sx={{
  variant: 'buttons.primary'
  modifiers: {
    size: 'small',
    shape: 'pill'
  }
}}

Creating variants with modifiers

We could abstract even more things to modifiers like colors and create a variant out of modifiers:

  primary: {
    modifiers: { color: 'primary', shape: 'rounded' },
  },
  secondary: {
    modifiers: { color: 'secondary', shape: 'rounded' },
  },
  pill: {
    modifiers: { color: 'orange', shape: 'pill', size: 'small' },
  }
  modifiers: {
    color: {...},
    size: {...},
    shape: {...},
  }

By doing that we can explicitly declare named variants and have the ability to modify then in an acceptable way.
Not by allowing creating invalid variant combinations like variants: ['primary', 'secondary'].

Responsiveness

sx={{
  variant: 'buttons.primary'
  modifiers: [{ size: 'small' }, { size: 'large' }]
}}

Advantages

  • Scoped to the current variant
  • Keep the design system consistent
  • Do not allow invalid combinations
  • Better responsiveness

@cour64
Copy link

cour64 commented Feb 24, 2020

Although I like your proposed solution @brunnolou. I'm still in favour of the multiple variants prop as it's much simpler to use.

Not sure where the distinction of modifiers/tweaks and variants is but to me anything that changes a style on a component creates a stylistic variation of that component and can thus be called a variant. If you have a small variant and a primary variant no matter the styles each applies they still create style variations of the button. Anyway that's my understanding of the nomenclature.

This syntax, for me at least, is easiest to grok:

sx={{
  variants: ['buttons.color.primary', 'buttons.size.small', 'buttons.shape.pill']
}}

I do agree with @brunnolou that there could be some confusion with what to expect when the variant and variants props are both used concurrently.

@brunnolou
Copy link

brunnolou commented Feb 25, 2020

Theme as a stylesheet and variants as classNames 🤯

sx={{
  variants: ['buttons.color.primary', 'buttons.size.small', 'buttons.shape.pill']
}}

@cour64 one of my biggest issues with this is the power. it would easily be misused.
Nothing will prevent anyone using the theme as a stylesheet and sx={{ variants: […] }} as classNames!
All the design system constraints could be lost.

We could see something like this:

sx={{
  variants: ['buttons.primary', 'utilClasses.myCustomStyle', 'utilClasses.becauseICan']
}}

Difference between tweak and variants

Not sure where the distinction of modifiers/tweaks and variants is but to me anything that changes a style on a component creates a stylistic variation of that component and can thus be called a variant.

Following the BEM methodology, a "tweak" is the modifier:

"defines the appearance, state, or behavior of a block or element."

A block or element in our context it's our components. IMO each variant alone defines the whole style of the component.

  • sx={{ variant:"buttons.primary" }} and sx={{ variant:"buttons.secondary" }} should render a button.
  • In your example if you define just the size variant, for example, then variants: ['buttons.size.small'] will render an unstyled small button.

Reinforcing the design system

So this is how I believe we could reinforce the design system constraints:

  • A variant defines the main style and can be used alone
  • A variant can be extended or modified with it owns modifiers
  • A modifier shouldn’t be used alone without a variant

@jonavila
Copy link

Since emotion supports composition by passing an array to the css prop, I opened a PR to revive the work from #174 to allow the same composition in the sx prop. The new PR is #704. If we allow arrays in sx, we can compose the variants that way. (https://emotion.sh/docs/composition).

At the moment, I have to use the css prop and wrap the array of objects with the css utility from theme-ui.

In the app I'm working on, I also have a one-off case where I need to compose various variants into a single variant. The workaround I've been using at the moment is by leveraging Emotion's & selector for the current class and combining variants like:

{
    colors: {
        primary: 'blue'
    },
    button: {
        primary: {
            bg: 'primary'
        }
    },
    text: {
        caps: {
            textTransform: 'uppercase',
        },
        muted: {
            color: 'gray1',
        },
    },
   {
    someSuperVariant: {
        variant: 'button.primary',
        '&': {
            variant: 'text.caps',
        },
    },       
   }
}

@medelman17
Copy link

For what it’s worth, I published a ‘plugin’ of sorts that enables the above, BEM-like functionality by overloading either the sx or css prop. Find it 👉🏻 https://github.com/fabulascode/theme-ui-modifiers

It’s early stages yet—edge cases haven’t been considered, etc.—but I like it’s functionality, the ergonomics, imho, are a better fit for my mental model—despite its obvious hacky-ness due to not being officially supported. 🤷🏻‍♂️

Here’s how it works generally (you can also use the css prop):

/** @jsx jsx */
import { jsx, Text as ThemeUIText } from 'theme-ui';
import { useModifiers, getVariant } from '@fabulas/theme-ui-modifiers';

const TextModifiers = {
  bold: { fontWeight: 700 },
  orange: { color: 'orange' },
  bigger: ({ theme, variant }) => {
    const { fontSize } = getVariant(theme, variant);
    if (Array.isArray(fontSize)) {
      return { fontSize: fontSize.map(s => s + 1) };
    }
    return { fontSize: fontSize + 1 };
  },
};

function Text({ children, ...props }) {
  const modifiers = useModifiers(props);

  return (
    <ThemeUIText variant={props.variant} sx={modifiers}>
      {children}
    </ThemeUIText>
  );
}

function App() {
  return (
    <div>
      <Text variant="h1" modifiers={['bigger', 'bold', 'orange']}>
        Hello!
      </Text>
    </div>
  );
} 

Would love to hear your thoughts and or ideas.

@brunnolou
Copy link

@medelman17 I that's interesting, I like the useModifiers idea. I've just found two issues here.

Responsiveness

Like I mentioned before IMO using object notation would be better to be compatible with responsiveness.
E.g.: <Text variant="h1" modifiers={[{ weight: 'bold' }, { weight: 'normal' }]}.

Theming

Keeping the modifiers at the component level we loose "themeability". Meaning that even if we change the theme, the modifiers will always remain the same.

Suggestion

Using your idea maybe something like this could tackle these two issues:

  1. Add modifiers to the theme
// theme.js
buttons: {
  primary: { ...  },
  secondary: { ...  },
  modifiers: {
    size: {
      small: { fontSize: 1 },
      large: { fontSize: 3 },
    },
    shape: {
      rounded: { borderRadius: 4 },
      pill: { borderRadius: 100 },
    },
  },
},
  1. Get modifiers from the theme by variant and modifiers names.
function Button({ variant = 'primary', children, ...props }) {
  // By variant and modifiers names.
  const modifiers = useModifiers(`buttonns.${variant}`, props.modifiers);

  return (
    <ThemeUIButton variant={props.variant} sx={modifiers}>
      {children}
    </ThemeUIButton>
  );
}
<Button modifiers={{ shape: 'pill' }} />
<Button variant="secondary" modifiers={[{ size: 'small' }, { size: 'large' }]} />

That would be the general usage, but if we want a better API for the componensts, we could create a different Buttons component implementation to map props to modifiers (before passing to useModifiers), to have sth liked this:

<Button  variant="secondary" shape="pill" size="small" />

@medelman17
Copy link

medelman17 commented Feb 26, 2020 via email

@brunnolou
Copy link

@jonavila I like the idea of having arrays in the sx prop.

The new PR is #704. If we allow arrays in sx, we can compose the variants that way.

Are you saying that the variants should also be merged?
What we would expect from something like these?

<Box sx={[{ variant: 'buttons.primary' }, { variant: 'buttons.seconday' }]}

Replace the primary variant with secondary or merge them? 🤔

If merged, then this would be possible (even though looks a bit weird):

<Box sx={[{ variant: 'buttons.primary' }, { variant: 'buttons.sizes.large' }, { variant: 'text.caps' }]}

Regarding the need for extending multiple variants, I can see the benefit, especially while defining the theme.
But like I mentioned #586 (comment) it's too powerful and can break the design system.

Conclusion

I think both ideas are valid and coexist.

  1. Being able to extend different variants (theme only 🤔?):
// theme.js
buttons: {
    primary: { ... },
    attention: {
        variants: ['button.primary', 'text.caps'],
        // Or
        extends: ['button.primary', 'text.caps'],
    },
}
  1. Add modifiers for the same variant
// theme.js
buttons: {
  primary: {
    modifiers: { color: 'primary', shape: 'rounded' },
  },
  attention: {
    extends: ['button.primary', 'text.caps'],
    modifiers: { size: 'large' },
  },
  modifiers: {
    color: { ... },
    size: { ... },
    shape: { ... },
  },
}

I think we should make clear this distinction: "multiple variants" vs "modifiers".
I'd love to know @jxnblk opinion about all this.

@cour64
Copy link

cour64 commented Feb 26, 2020

@lachlanjc I think the problem with the implementation in the css package has to do with the handling of responsive styles here:

const styles = responsive(obj)(theme)

where the variants array is being treated as a responsive style and only the first element of the variants prop is being treated as the base style and then all subsequent elements get nested in media queries.

Perhaps checking the key within the responsive function so as to properly handle the variants prop could be a solution, albeit a bit naive? Something a long the lines of:

const responsive = styles => theme => {
  const next = {}
  const breakpoints =
    (theme && (theme.breakpoints as string[])) || defaultBreakpoints
  const mediaQueries = [
    null,
    ...breakpoints.map(n => `@media screen and (min-width: ${n})`),
  ]

  for (const key in styles) {
    const value =
      typeof styles[key] === 'function' ? styles[key](theme) : styles[key]

    if (value == null) continue

    if (!Array.isArray(value)) {
      next[key] = value
      continue
    }

    // check if the key is 'variants' and handle appropriately
    if (key === 'variants') {
      for (let i = 0; i < value.length; i++) {
        const variant = flatten([
          typeof value[i] === 'function' ? value[i](theme) : value[i],
        ])

        if (variant == null) continue

        if (!Array.isArray(variant)) {
          next[key] = [...(next[key] || []), variant]
          continue
        }

       // this handles responsive variants of with a 2d array so ['primary', ['small', 'medium']]
        for (let i = 0; i < variant.slice(0, mediaQueries.length).length; i++) {
          const media = mediaQueries[i]
          if (!media) {
            next[key] = [...(next[key] || []), variant[i]]
            continue
          }
          next[media] = next[media] || {}
          if (variant[i] == null) continue
          next[media][key] = [...(next[media][key] || []), variant[i]]
        }
      }
      continue
    }

    for (let i = 0; i < value.slice(0, mediaQueries.length).length; i++) {
      const media = mediaQueries[i]
      if (!media) {
        next[key] = value[i]
        continue
      }
      next[media] = next[media] || {}
      if (value[i] == null) continue
      next[media][key] = value[i]
    }
  }

  return next
}

and then applying the variants prop in the css function like so:

if (key === 'variants') {
  // wrap val in an array and then flatten in case a single string is passed
  const variants = css(
    deepmerge.all(flatten([val]).map(v => get(theme, v)))
  )(theme)
  result = { ...result, ...variants }
  continue
}

The flatten function here would take a deeply nested array and flatten it out indefinitely to be a 1d array, this would allow defining responsive variants by using a 2d array as well as handle the case where a single string is passed instead of an array of variants.

There might be a couple specificity issues with this implementation though.

@jonavila
Copy link

@jonavila I like the idea of having arrays in the sx prop.

The new PR is #704. If we allow arrays in sx, we can compose the variants that way.

Are you saying that the variants should also be merged?
What we would expect from something like these?

<Box sx={[{ variant: 'buttons.primary' }, { variant: 'buttons.seconday' }]}

Replace the primary variant with secondary or merge them? 🤔

If merged, then this would be possible (even though looks a bit weird):

<Box sx={[{ variant: 'buttons.primary' }, { variant: 'buttons.sizes.large' }, { variant: 'text.caps' }]}

Regarding the need for extending multiple variants, I can see the benefit, especially while defining the theme.
But like I mentioned #586 (comment) it's too powerful and can break the design system.

@brunnolou Yes, they will be merged because behind the scenes the sx prop is using emotion's css prop. So it will work exactly like the last example on this page: https://emotion.sh/docs/composition

In that example, the composition overrides just the color property, but it leaves the background-color untouched. Per their docs:

Using Emotion’s composition, you don’t have to think about the order that styles were created because the styles are merged in the order that you use them.

@jxnblk
Copy link
Member

jxnblk commented Mar 2, 2020

Thanks for bringing up some of these ideas, all, but this level of discussion should probably happen in an issue rather than this PR. The proposal here is to add an additional variants property that can deeply merge multiple variants. The notion of modifiers from other CSS methodologies is identical to variants, and I do not think it's worth adding another name for this. The proposal for using arrays in the sx prop would not merge variants, and should be considered a separate issue. We'd like to ship this current proposal, but if you have additional thoughts outside the scope of this PR, please create a new issue

@cour64
Copy link

cour64 commented Mar 2, 2020

The solution I proposed here #586 (comment) works for the most part but there's one issue I noticed.

If a variant is used within a variant e.g.

primary: {
  p: 3,
  fontWeight: 'bold',
  color: 'white',
  bg: 'primary',
  borderRadius: 2,
},
disabled: {
  variant: 'buttons.primary',
  bg: 'muted',
}

Then some style props could incorrectly be overwritten e.g. the output above merged would result in:

{
   p: 3,
   fontWeight: 'bold',
   color: 'white',
   bg: 'muted',
   borderRadius: 2,
   variant: 'buttons.primary'
}

the bg prop defined in the disabled variant would be overwritten by the styles props from primary variant despite it being specified before the bg prop. This is due to how the deepmerge package merges two objects and updates props in-place instead of keeping the order of props from the source object. There's actually an issue about it here TehShrike/deepmerge#169 although I don't think deepmerge is the culprit with regards to the style props being overwritten.

Hope this all makes sense.

@lachlanjc
Copy link
Member Author

Thanks for bringing up some of these ideas, all, but this level of discussion should probably happen in an issue rather than this PR. The proposal here is to add an additional variants property that can deeply merge multiple variants. The notion of modifiers from other CSS methodologies is identical to variants, and I do not think it's worth adding another name for this. The proposal for using arrays in the sx prop would not merge variants, and should be considered a separate issue. We'd like to ship this current proposal, but if you have additional thoughts outside the scope of this PR, please create a new issue

Thanks for the input @jxnblk, I stopped working on this since it seemed like it might go to waste. Will try to look back at getting this toward the finish line sometime soon. Even deciding that we don’t want to move forward with some of the other proposals here, glad we discussed them!

@dburles
Copy link
Contributor

dburles commented Apr 17, 2020

I like some of the ideas with this but I definitely think it needs to be fleshed out a bit! It's a big conceptual change to the meaning of a variant, and I think it's overloading the variant API a bit unnecessarily. I don't think there is any reason why these kinds of variants couldn't just be handled in the component itself via props.

I quite like the modifiers suggestion by @brunnolou #586 (comment) and funnily enough the useModifiers hook looks very similar to what I implemented for the variant prop idea over here: PR #823 (and proposal #800). So I think these changes together could potentially work well to support this idea.

The interesting thing is you can already implement most of this already outside of theme-ui and basically ignore the variant CSS property. The only part missing there is the ability to easily apply variant styles to any element, e.g.

<button sx={{ variant: 'buttons.primary' }}>...</button>

In this case the jsx function in core library requires a change to support the variant prop.

<button variant="buttons.primary">...</button>

@brunnolou

That would be the general usage, but if we want a better API for the componensts, we could create a different Buttons component implementation to map props to modifiers (before passing to useModifiers), to have sth liked this:

<Button variant="secondary" shape="pill" size="small" />

Perhaps the combination of these hooks would be perfect:

const Button = ({
  variant,
  shape,
  size,
  ...props
}) => {
  const modifierStyle = useModifiers('buttons', { variant, shape, size });

  return ...;
});

Also this would allow you to easily style multiple elements with the modifierStyle too! 🎉. (See #823 for an example with the variant prop and useVariant hook).

Edit: I've just edited this with a revision to drop the useVariant hook for a single useModifiers which simplifies the whole thing as they were basically doing the same job.

@dburles
Copy link
Contributor

dburles commented Apr 17, 2020

Here's an example implementation of the idea above to play around with: https://codesandbox.io/s/theme-ui-hook-api-example-9nnmu?file=/src/index.js

@lachlanjc
Copy link
Member Author

Closing since there are many other PRs for this functionality now, & the files have changed since the PR was made

@lachlanjc lachlanjc closed this Nov 12, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants