Skip to content

Conversation

adamwathan
Copy link
Member

@adamwathan adamwathan commented Feb 27, 2018

Update 2018-03-13: A lot of syntax has changed since this PR, be sure to see #412 for the changes until the plugin system has complete documentation.


This PR introduces a new plugin system to Tailwind designed to allow end-users to add their own utilities, components, or even full blown themes.

How plugins work

Plugins are just functions that accept an object that can be destructured into a few useful helpers:

function ({selector, addUtilities, addComponents}) {

  // Use `selector` to define new CSS rules, like utilities:
  const utilities = [
    selector('.object-fill', {
      'object-fit': 'fill',
    }),
    selector('.object-contain', {
      'object-fit': 'contain',
    }),
    selector('.object-cover', {
      'object-fit': 'cover',
    })
  ]

  // ...or components 😏:
  const components = [
    selector('.btn-blue', {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': '.25rem',  
    })
    selector('.btn-blue:hover', {
      'background-color': 'darkblue',
    })
  ]

  // Use `addUtilities` to register new utilities with Tailwind.
  // 
  // These will be inserted *after* Tailwind's built-in utilities.
  // You can also pass an array of variants to generate for plugin utilities.
  addUtilities(utilities, ['responsive'])

  // Use `addComponents` to register new component classes with Tailwind.
  // 
  // These will be inserted at the new `@tailwind components` directive.
  addComponents(components)
}

Registering plugins

To register a new plugin, add it to the plugins section of your config file:

{
  // ...
  plugins: [
    function ({ selector, addUtilities }) {
      addUtilities([
        selector('object-fill', {
          'object-fit': 'fill',
        }),
        selector('object-contain', {
          'object-fit': 'contain',
        }),
        selector('object-cover', {
          'object-fit': 'cover',
        })
      ], ['responsive', 'hover'])
    },
  ]
}

Importing plugins from external modules

Plugins can of course be place in separate modules, so in practice this will end up looking more like this:

// tailwindcss-object-fit-utils:
export default function (variants) {
  return function ({ selector, addUtilities }) {
    addUtilities([
      selector('object-fill', {
        'object-fit': 'fill',
      }),
      selector('object-contain', {
        'object-fit': 'contain',
      }),
      selector('object-cover', {
        'object-fit': 'cover',
      })
    ], variants)
  }
}

// Your Tailwind config:
{
  // ...
  plugins: [
    require('tailwindcss-object-fit-utils')(['responsive', 'hover', 'focus']),
  ]
}

Configuring plugins

As you might have gleaned from the above example, the recommended pattern for plugins that need configuration is to export a function that accepts your configuration options in whatever format you want, then returns the actual plugin function, which will then have access to the plugin configuration from the parent scope.

That means a buttons plugin might look like this to use:

// Your Tailwind config:
{
  // ...
  plugins: [
    require('tailwindcss-simple-buttons')({
      sizes: {
        default: {
          fontSize: '1rem',
          padding: '.5rem 1rem',
        },
        sm: {
          fontSize: '.875rem',
          padding: '.25rem .5rem',
        },
        lg: {
          fontSize: '1.25rem',
          padding: '1rem 2rem',
        },
      },
      colors: {
        primary: {
          bg: colors['blue'],
          text: colors['white'],
        },
        secondary: {
          bg: colors['grey-light'],
          text: colors['black'],
        }
      }
    }),
  ]
}

...and be implemented like this:

import _ from 'lodash'
import Color from 'color'

export default function (options) {
  return function ({ selector, addComponents }) {
    const buttonSizes = _.map(options.sizes, ({ fontSize, padding }, size) => {
      return selector(size === 'default' ? '.btn' : `.btn-${size}`, {
        'font-size': fontSize,
        'padding': padding,
      })
    })

    const buttonColors = _.flatMap(options.colors, ({ bg, text }, name) => {
      return [
        selector(`.btn-${name}`, {
          'background-color': bg,
          'color': text,
        }),
        selector(`.btn-${name}:hover`, {
          'background-color': Color(bg).darken(0.2).string(),
        }),
      ]
    })

    addComponents([
      ...buttonSizes,
      ...buttonColors,
    ])
  }
}

If you're seeing this and saying to yourself, "wow, I might as well just write the CSS at this point," congratulations, now you understand why we didn't bother shipping configurable components with Tailwind in the first place :D

How a plugin's configuration is structured is entirely up to the plugin author; Tailwind only cares about the actual plugin function you eventually provide it.

Default configuration values

There's nothing stopping a plugin author from providing default configuration options of course, but Tailwind itself doesn't do anything for you in this regard; it's up to the plugin author to implement as she sees fit.

For example, that button plugin might have some default sizes based on Tailwind's default font sizes and spacing sizes, and it might generate buttons for all of the default Tailwind colors, unless you override that with your own colors.

Again the sky is the limit here, it's Just JavaScript™ so plugin authors can do whatever they want.

Component states

You might have noticed that unlike addUtilities, addComponents doesn't take the extra "state variants" parameter. That's because typically it doesn't make any sense for a true "component class" to only be "activated" by some interaction like hover or focus.

Instead, unlike utilities, many components need to apply different styles based on their state, like how a button might change color on hover.

For now, plugin authors should treat the different states of their components as completely separate selectors, like demonstrated in the very first component example:

function ({selector, addUtilities, addComponents}) {
  const components = [
    selector('.btn-blue', {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': '.25rem',  
    })
    selector('.btn-blue:hover', {
      'background-color': 'darkblue',
    })
  ]

  addComponents(components)
}

I might try to create a nicer API for this sort of thing in the future, but it would really just be sugar for this same code.

Accessing existing config values

I've explicitly decided not to pass the user's Tailwind config to the plugin function to discourage plugin authors from depending on certain keys existing that are not actually guaranteed to exist.

For example, I don't want someone writing a button plugin that tries to use config.padding[4] for horizontal padding, because users are encouraged to customize those scales to fit their needs.

Instead, if a plugin wants to default to using values from Tailwind's default config, the plugin should import Tailwind's default config and reference that:

const defaultConfig = require('tailwindcss/defaultConfig')()

export default function (options) {
  return function ({ selector, addComponents }) {
    addComponents([
      selector('.card', {
        'padding': _.get(options, 'padding', `${defaultConfig.padding[4]}`)
      })
    ])
  }
}

If you as the end user have customized your config and want to reuse those values to pass into plugins, use variables (like we already do for colors); your config file is Just JavaScript™ after all:

let spacingScale = {
  sm: '.5rem',
  md: '1rem',
  lg: '4rem',
}

modules.exports = {
  // ...
  padding: spacingScale,
  margin: spacingScale,
  // ...
  plugins: [
    require('tailwindcss-basic-cards')({
      padding: spacingScale.md
    }),
  ],
  // ...
}

How would themes work?

A "theme" or full blown "component kit" would really just be a plugin that adds many components at once.

Since all of this stuff is Just JavaScript™, a theme could totally provide color palettes, type scales, spacing scales, etc. as well:

// Your Tailwind config:
let theme = require('tailwindcss-startup-theme')

let colors = theme.colorPalette()

module.exports = {
  colors: colors,
  fonts: theme.fonts(),
  padding: theme.spacingScale(),
  margin: theme.spacingScale(),
  plugins: [
    ...theme.components()
  ]
}

A truly "done for you" theme could even provide an entire Tailwind configuration:

// Your Tailwind config:

module.exports = require('tailwindcss-enterprise-theme')()

Coming next

  • An atRule helper that will be useful for components that need to change at different breakpoints, for example our existing container component:

    export default function ({breakpoints}) {
      return function ({ selector, atRule, addComponents }) {
        const containerClasses = [
          selector('.container', {
            'width': '100%',
          })
        ]
    
        breakpoints.forEach((breakpoint) => {
          const mediaQuery = atRule(`@media (min-width: ${breakpoint})`, [
            selector('.container', { 'max-width': breakpoint })
          ])
          containerClasses.push(mediaQuery)
        })
    
        addComponents(containerClasses)
      }
    }
  • Possibly a component helper that makes it easier to define hover/focus/etc states for a component class in a single function call

  • Possibly a escape helper to make it easier to escape any user provided strings that are going to be used in class names, like we do already for things like w-1/4

Questions

  • How should the prefix option affect classes generated by plugins?

    My inclination is we do nothing by default, but perhaps pass the prefix to the plugin so the plugin can incorporate it however it needs to if it makes sense.

  • How should the important option affect classes generated by plugins?

    Again, I think we do nothing by default and just pass the information along to the plugin.

@HellPat
Copy link

HellPat commented Feb 27, 2018

Hey @adamwathan

NICE!! I really love it.
I have a few comments though (they're just thoughts, maybe stupid, I didn't test anything)

First (and very minor) is naming of the selector function.

selectors are used to target the HTML elements on our web pages that we want to style.

See https://developer.mozilla.org/en-US/docs/Learn/CSS/Introduction_to_CSS/Selectors

We have selectors (plural) and the declarations as parameters. So the function defines one rule or ruleset.

The signature of the function could be:

rule(string selector(s), array rules): postcss.rule[]

(Sorry, I don't know Javascipt at all :-) )

This is also equivalent to the postcss naming.

Signature of plugins

In your example you inject addComponents and addUtilities to the function.
Maybe we can just return rules there?
It's not a real difference if it's a utility or a component from a technical perspective.
It's just a CSS rule.

I'm quite sure you're more clever than I am here, but this is just what I thought.
Am I wrong? What is the benefit from addComponents and addUtilities?

prefix and important

Maybe you already guessed that I will comment on every discussion where !important is involved. I really love it ;-)

prefix

My inclination is we do nothing by default

👍

but perhaps pass the prefix to the plugin so the plugin can incorporate it however it needs to if it makes sense.

I can imagine that many authors of plugins just won't care.
They will write the plugin for their usecase.

Maybe we could use the "decorator" pattern here and provide an additional function.

If our plugin "just" returns rules we could decorate it with an additional function.

If this is the signature of the plugin function:

function(...options): postcss.rule[]

we could wrap it:

let plugin = prefix(myPlugin, 'my-custom-prefix)';
// plugin() => postcss.rule[]

Same could be possible for my loved important.

This is just a thought on the "the plugin author didn't care" and does not collide with your idea to passing the options to the plugin.
The author can add custom behaviour if he wants by using your options

@adamwathan
Copy link
Member Author

Hey @psren thank you for chiming in!

First (and very minor) is naming of the selector function.

I agree! I will rename this to rule.

What is the benefit from addComponents and addUtilities?

They get injected in different places in the CSS: components are added after @tailwind components and utilities are added after @tailwind utilities. If we just returned a single rules array we'd have to sort through them somehow to figure out which ones are utilities and which ones are components to make sure that components were always inserted before utilities so that utilities can still override them.

I can imagine that many authors of plugins just won't care.

Yeah this is a potential problem although I'm not too worried about it; people will just not use that plugin or they will complain.

The reason I want to defer this to the plugin author is that our plugin system doesn't enforce any restrictions about the types of rules you can create.

Someone could make a rule like:

rule('body > a.foo[type=bar]:last-child > span + .baz', { ... })

...so it's not immediately obvious how to prefix that sort of thing or if it should matter. Same with important; components probably shouldn't be important but utilities should.

I think the ultimate solution is to defer to the plugin author but also provide as much as we can helper-wise to make it very easy to respect the user's configuration.

@milosdakic
Copy link

I might not be seeing it, but is there a way to create components using existing utilities?

https://tailwindcss.com/docs/extracting-components

The ability to create a package out of standard components would be amazing.

@inxilpro
Copy link

A couple initial thoughts.

First, why expose selector() at all? It seems to me that the API could be simplified to (config() explained below):

export default function ({ addUtilities, addComponents, config }) {

  const radius = config('buttonRadius', '.25rem', 'borderRadius.default');

  addUtilities({
    '.object-fill': {
      'object-fit': 'fill',
    },
    '.object-contain': {
      'object-fit': 'contain',
    },
    '.object-cover': {
      'object-fit': 'cover',
    }
  });

  addComponents({
    '.btn-blue': {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': radius,
    },
    '.btn-blue:hover': {
      'background-color': 'darkblue',
    }
  });
}

addUtilities and addComponents should probably accept a single object or an array of objects so you could map over a bunch of values inside your plugin.

Second, while I completely understand the reasoning behind not exposing the tailwind config to the plugin, I think it limits many of the most promising use cases. If you don't have access to the tailwind config, what's the value in a tailwind plugin over just a PostCSS plugin? I think that something along the lines of my proposed config() function would address your concerns while still exposing the config.

The signature would be something like:

config(configName, defaultValue[, defaultConfigPath]);
  • configName is the plugin's name for this configuration value
  • defaultValue is the plugin's default value
  • defaultConfigPath is an optional dot-separated path to the value in the user's tailwind config

So, given the default tailwind config, you might call the plugin in two ways:

// Override the default value
{ plugins: [ myPlugin({ buttonRadius: '.5rem' }) ] }

// Use the borderRadius.default value
{ plugins: [ myPlugin() ] }

// Fall back to default value
{
  borderRadius: {
    foo: '20rem'
  }
  plugins: [
    myPlugin()
  ]
}

While many people will customize their tailwind config heavily, I'm guessing that the overwhelming majority of people will keep many of the configuration names the same.

Third, have you explored building tailwind's core functionality as plugins? I feel like generating a Tailwind documentation-quality style guide based on your specific tailwind config has tremendous promise, and if plugins automatically documented themselves in the same way by default, that would be amazing.

@inxilpro
Copy link

config might even want to take an array of paths to try from the user's config. Something like:

const defaultConfig = require('tailwindcss/defaultConfig')();
const red = defaultConfig.colors.red;
const deleteButtonColor = config('buttonColor', red, ['colors.red', 'colors.danger']);

When you think about the most likely use-cases here, there's maybe even an argument for just defaulting to the defaultConfig value:

const deleteButtonColor = config('buttonColor', ['colors.red', 'colors.danger'], '#ff0000');

This would try (in this order):

  1. userConfig.colors.red
  2. userConfig.colors.danger
  3. defaultConfig.colors.red
  4. defaultConfig.colors.danger
  5. "#ff0000"

I would venture a guess that 99% of the time, config('radius', 'borderRadius.default') would work just fine. It would let the user configure the radius value when instantiating the plugin, it would use the user's configured borderRadius.default value if it existed, and it would fall back on the default config's borderRadius.default value if it needed to.

Thoughts?

@ConsoleTVs
Copy link

+1 for @milosdakic

I agree withthe selector() rename to rule()

Great job!

@adamwathan
Copy link
Member Author

@milosdakic:

I might not be seeing it, but is there a way to create components using existing utilities?

This approach can't really be taken with plugins because there's no way to guarantee what utilities exist and how they're named.

For example, the spacing scale is totally customizable, so someone might configure their build such that there is no .mb-4 class anymore and instead there's a .mb-sm.

Instead, you'll need to pass the values you want to use into the plugins themselves, so if you want a button plugin to use your customized spacing values, you'd just pass that information into the plugin as configuration. The plugin can totally have it's own defaults that mirror values from Tailwind's default config, but they won't actually be dependent on specific utility class names or values existing.

@adamwathan
Copy link
Member Author

@inxilpro Thanks for the thoughtful input! Thoughts below:

First, why expose selector() at all? It seems to me that the API could be simplified to...

This is a valid point! My main argument for this is that I think ultimately I want to provide two ways to create rules.

selector (now rule) is the "raw" version, where you have complete control over the rule's selector and it's not manipulated for you at all.

For example:

rule('.w-1/4', { ... })

...would create this rule, where the / is not escaped:

.w-1/4 { ... }

This is most useful when you are manually applying pseudo classes like :hover, ::before, etc. where you wouldn't want the colons escaped.

I'd also like to add a function like utility that tries to be a bit more helpful about escaping, so:

utility('.w-1/4', { ... })

...would generate:

.w-1\/4 { ... }

That function could also allow you to omit the leading . if you wanted, since all utilities are classes anyways and that's just sort of annoying boilerplate.

Admittedly there's another way to solve this whole problem though which is to just expose a helper like escape that you can use however you like:

addUtilities({
  [`.${escape('w-1/4')`]: { ... }
})

Just a bit more verbose, although for this first pass of the feature I don't think I mind. I think it's better to start with providing raw/low-level access for maximum flexibility and layer on additional convenience features later vs. focusing on convenience out of the gate.

I'm going to explore using the API you suggested + providing an escape helper and see how it goes 👍

If you don't have access to the tailwind config, what's the value in a tailwind plugin over just a PostCSS plugin?

There's basically no benefit other than to try and make authoring plugins more accessible; you're totally right you could do all this with a regular PostCSS plugin!

I think that something along the lines of my proposed config() function would address your concerns while still exposing the config.

This is a lot more complexity than I'd like to commit to right now, and has the unfortunate side effect of forcing a standard signature on the configuration of every plugin.

I'd rather just pass in the raw config object and leave it to the plugin author to do any fancy crap with checking if keys are defined and falling back to defaults and stuff.

It's very easy for a plugin author to rewrite this:

config('radius', 'borderRadius.default')

...as:

_.get(pluginConfig, 'radius', _.get(tailwindConfig, 'borderRadius.default', '.25rem'))

...without us having to figure out and commit to any fancy API for doing this across all plugins. Again, could totally explore more convenience features down the road but don't want to accidentally cripple anything or create unnecessary limitations by trying to be too clever or try to remove too much responsibility/control from the plugin author.

I'll update things to pass the config through though; even if I think it can easily be abused you're right that there are ways to use it responsibly that can make configuration less burdensome 👍

@inxilpro
Copy link

inxilpro commented Mar 1, 2018

@adamwathan

This is a lot more complexity than I'd like to commit to right now, and has the unfortunate side effect of forcing a standard signature on the configuration of every plugin.

I'm going to push back once on this, in that I think this is going to be a really important part of Tailwind plugins. I don't think it necessarily has to be complicated, and I think providing an API that doesn't solely rely on the keys from the default configuration would set everyone up for success. I also think there's a way to let plugins accept arbitrary config—anything from strings to callbacks—within the framework of what I proposed.

In an attempt to not fill this thread with a ton of code, I created a gist with some ideas:

https://gist.github.com/inxilpro/0f31c4ec164a1b56f76092ba98ed5f6e

@adamwathan
Copy link
Member Author

Leaving the original PR message untouched for historical reasons, but here's an updated version of the same message that includes the changes that have been made since then.

Changes of note:

  • The selector function has been renamed to rule
  • A new atRule function for wrapping any of your plugin's rules in at-rules, like media queries
  • A new escape function to make it easy to escape portions of class names that might come from the user, so users can safely use non-standard characters in their class names like / or :
  • A new prefix function for applying the user's configured prefix to a selector
  • A new config function for retrieving values from the user's Tailwind configuration
  • A new utility function that has the same signature as rule but removes a lot of boilerplate when you want the class name automatically escaped, and the user's prefix and important preferences automatically respected

This PR introduces a new plugin system to Tailwind designed to allow end-users to add their own utilities, components, or even full blown themes.

How plugins work

Plugins are just functions that accept an object that can be destructured into a few useful helpers:

function ({ rule, atRule, escape, prefix, config, utility, addUtilities, addComponents }) {

  // Use `rule` to define new CSS rules, like utilities:
  const utilities = [
    rule('.object-fill', {
      'object-fit': 'fill',
    }),
    rule('.object-contain', {
      'object-fit': 'contain',
    }),
    rule('.object-cover', {
      'object-fit': 'cover',
    }),
  ]

  // ...or components:
  const components = [
    rule('.btn-blue', {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': '.25rem',  
    }),
    rule('.btn-blue:hover', {
      'background-color': 'darkblue',
    }),
  ]

  // Use `atRule` to wrap your own rules in at-rules, like media queries:
  components.push([
    rule('container', {
      'width': '100%',
    }),
    atRule('@media (min-width: 500px)', [
      rule('container', {
        'max-width': '500px'
      }),
    ]),
    atRule('@media (min-width: 800px)', [
      rule('container', {
        'max-width': '800px'
      }),
    ]),
  ])

  // Use `escape` to escape anything that might contain non-standard
  // characters but will be used as part of a class name:
  utilities.push([
    rule(`.skew-${escape(keyFromUser)}`, {
      'transform': `skewY(${valueFromUser})`,
    })
  ])

  // Use `prefix` for applying the user's configured prefix to your plugins
  // selectors, if it makes sense:
  utilities.push([
    rule(prefix('.border-collapse'), {
      'border-collapse': 'collapse',
    })
  ])

  // Use `config` to access values from the user's Tailwind configuration.
  // 
  // You should prefer explicit plugin configuration to this, but used
  // responsibly it can simplify your plugin's API in certain cases.
  components.push([
    rule('.card', {
      'border-radius': config('borderRadius.default', '.25rem'),
    })
  ])

  // Use `utility` for defining new utilities that are automatically escaped
  // and automatically respect the user's `prefix` and `important` options:
  utilities.push([
    utility('.rotate-1/4', {
      'transform': 'rotate(90deg)',
    })
  ])

  // Use `addUtilities` to register new utilities with Tailwind.
  // 
  // These will be inserted *after* Tailwind's built-in utilities.
  // You can also pass an array of variants to generate for plugin utilities.
  addUtilities(utilities, ['responsive'])

  // Use `addComponents` to register new component classes with Tailwind.
  // 
  // These will be inserted at the new `@tailwind components` directive.
  addComponents(components)
}

Registering plugins

To register a new plugin, add it to the plugins section of your config file:

{
  // ...
  plugins: [
    function ({ rule, addUtilities }) {
      addUtilities([
        rule('.object-fill', {
          'object-fit': 'fill',
        }),
        rule('.object-contain', {
          'object-fit': 'contain',
        }),
        rule('.object-cover', {
          'object-fit': 'cover',
        })
      ], ['responsive', 'hover'])
    },
  ]
}

Importing plugins from external modules

Plugins can of course be place in separate modules, so in practice this will end up looking more like this:

// tailwindcss-object-fit-utils:
export default function (variants) {
  return function ({ rule, addUtilities }) {
    addUtilities([
      rule('.object-fill', {
        'object-fit': 'fill',
      }),
      rule('.object-contain', {
        'object-fit': 'contain',
      }),
      rule('.object-cover', {
        'object-fit': 'cover',
      }),
    ], variants)
  }
}

// Your Tailwind config:
{
  // ...
  plugins: [
    require('tailwindcss-object-fit-utils')(['responsive', 'hover', 'focus']),
  ]
}

Configuring plugins

As you might have gleaned from the above example, the recommended pattern for plugins that need configuration is to export a function that accepts your configuration options in whatever format you want, then returns the actual plugin function, which will then have access to the plugin configuration from the parent scope.

That means a buttons plugin might look like this to use:

// Your Tailwind config:
{
  // ...
  plugins: [
    require('tailwindcss-simple-buttons')({
      sizes: {
        default: {
          fontSize: '1rem',
          padding: '.5rem 1rem',
        },
        sm: {
          fontSize: '.875rem',
          padding: '.25rem .5rem',
        },
        lg: {
          fontSize: '1.25rem',
          padding: '1rem 2rem',
        },
      },
      colors: {
        primary: {
          bg: colors['blue'],
          text: colors['white'],
        },
        secondary: {
          bg: colors['grey-light'],
          text: colors['black'],
        }
      }
    }),
  ]
}

...and be implemented like this:

import _ from 'lodash'
import Color from 'color'

export default function (options) {
  return function ({ rule, addComponents }) {
    const buttonSizes = _.map(options.sizes, ({ fontSize, padding }, size) => {
      return rule(size === 'default' ? '.btn' : `.btn-${size}`, {
        'font-size': fontSize,
        'padding': padding,
      })
    })

    const buttonColors = _.flatMap(options.colors, ({ bg, text }, name) => {
      return [
        rule(`.btn-${name}`, {
          'background-color': bg,
          'color': text,
        }),
        rule(`.btn-${name}:hover`, {
          'background-color': Color(bg).darken(0.2).string(),
        }),
      ]
    })

    addComponents([
      ...buttonSizes,
      ...buttonColors,
    ])
  }
}

If you're seeing this and saying to yourself, "wow, I might as well just write the CSS at this point," congratulations, now you understand why we didn't bother shipping configurable components with Tailwind in the first place :D

How a plugin's configuration is structured is entirely up to the plugin author; Tailwind only cares about the actual plugin function you eventually provide it.

Default configuration values

There's nothing stopping a plugin author from providing default configuration options of course, but Tailwind itself doesn't do anything for you in this regard; it's up to the plugin author to implement as she sees fit.

For example, that button plugin might have some default sizes based on Tailwind's default font sizes and spacing sizes, and it might generate buttons for all of the default Tailwind colors, unless you override that with your own colors.

Again the sky is the limit here, it's Just JavaScript™ so plugin authors can do whatever they want.

Component states

You might have noticed that unlike addUtilities, addComponents doesn't take the extra "state variants" parameter. That's because typically it doesn't make any sense for a true "component class" to only be "activated" by some interaction like hover or focus.

Instead, unlike utilities, many components need to apply different styles based on their state, like how a button might change color on hover.

For now, plugin authors should treat the different states of their components as completely separate selectors, like demonstrated in the very first component example:

function ({rule, addUtilities, addComponents}) {
  const components = [
    rule('.btn-blue', {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': '.25rem',  
    })
    rule('.btn-blue:hover', {
      'background-color': 'darkblue',
    })
  ]

  addComponents(components)
}

I might try to create a nicer API for this sort of thing in the future, but it would really just be sugar for this same code.

Accessing existing config values

Plugins receive a config function with the signature config(path, defaultValue) that they can use to fetch information from the user's Tailwind configuration:

export default function (options) {
  return function ({ rule, addComponents, config }) {
    addComponents([
      rule('.card', {
        'border-radius': config('border-radius.default', '.25rem')
      })
    ])
  }
}

Despite the fact that this function exists, be aware that this is not the recommended way for plugins to make themselves configurable. If anyone ever releases a plugin where the documentation says, "to specify the border radius of your cards, add a card key to your Tailwind borderRadius configuration" I will cry very sad tears.

Instead, your plugin should accept its own options so the end user can configure the plugin directly, rather than having the plugin reaching into the user's main Tailwind config and blindly stumble around looking for keys that aren't guaranteed to exist.

If you really wanted to create a plugin for a "card" component and wanted to use the user's default border radius value by default, the responsible way to implement it is to accept a border radius option in the plugin itself, fall back to the user's config if that option isn't provided, then ultimately fall back to a hardcoded value if that key doesn't exist in the user's config:

// Plugin source
export default function (options) {
  return function ({ rule, addComponents, config }) {
    addComponents([
      rule('.card', {
        'border-radius': _.get(options, 'borderRadius', config('border-radius.default', '.25rem'))
      })
    ])
  }
}


// Scenario A: Plugin is configured explicitly
let radiusScale = {
  default: '.25rem',
  // ...
}

module.exports = {
  // ...
  
  borderRadius: radiusScale,

  plugins: [
    require('card-plugin')({ borderRadius: radiusScale.default }),
  ]
}


// Scenario B: Plugin falls back to `borderRadius.default`
module.exports = {
  // ...
  
  borderRadius: {
    default: '.25rem',
    // ...
  },

  plugins: [
    require('card-plugin')(),
  ]
}


// Scenario C: Plugin falls back to hard-coded default because `borderRadius.default` does not exist
module.exports = {
  // ...
  
  borderRadius: {
    sm: '.125rem',
    md: '.25rem',
    lg: '.5rem',
  },

  plugins: [
    require('card-plugin')(),
  ]
}

important and prefix options

Tailwind exposes options for marking all utilities as !important as well as adding a custom prefix to all utilities so they can be more easily used with existing CSS.

The plugin system doesn't force these options on plugins (to avoid limiting creative use-cases we can't anticipate), but does provide some tools to make it easier for plugin authors to respect these options.

First is a prefix function that is passed into each plugin. This function expects a class name (including the leading .) and returns it with the prefix applied. If you want to apply the user's prefix to the classes your plugin generates, just run your selectors through the prefix function when creating your rules:

module.exports = {
  // ...
  plugins: [
    function({ rule, addUtilities, prefix }) {
      addUtilities([
        rule(prefix('.skew-12deg'), {
          transform: 'skewY(-12deg)',
        }),
      ])
    },
  ],
  options: {
    prefix: 'tw-',
    // ...
  },
}

// Produces:
// .tw-skew-12deg {
//   transform: skewY(-12deg);
// }

To determine if the user has the important option enabled, you can query the config:

module.exports = {
  // ...
  plugins: [
    function({ rule, addUtilities, config }) {
      addUtilities([
        rule('.skew-12deg', {
          transform: `skewY(-12deg)${config('options.important') ? ' !important' : ''}`,
        }),
      ])
    },
  ],
  options: {
    important: true,
    // ...
  },
}

// Produces:
// .skew-12deg {
//   transform: skewY(-12deg) !important;
// }

To make all of this easier, you can optionally use the utility helper instead of the rule helper, which assumes you want to respect the user's prefix and important options, and also automatically escapes the class name you provide:

module.exports = {
  // ...
  plugins: [
    function({ utility, addUtilities }) {
      addUtilities([
        utility('.rotate-1/4', {
          transform: 'rotate(90deg)',
        }),
      ])
    },
  ],
  options: {
    prefix: 'tw-',
    important: true,
  },
}

// Produces:
// .tw-rotate-1\\/4 {
//   transform: rotate(90deg) !important
// }

Note that the utility helper should only be used for simple rules with single class selectors. You should not use the utility helper in an attempt to automatically escape/prefix/important component classes, or rules with complex selectors.

How would themes work?

A "theme" or full blown "component kit" would really just be a plugin that adds many components at once.

Since all of this stuff is Just JavaScript™, a theme could totally provide color palettes, type scales, spacing scales, etc. as well:

// Your Tailwind config:
let theme = require('tailwindcss-startup-theme')

module.exports = {
  colors: theme.colorPalette(),
  backgroundColors: theme.colorPalette(),
  borderColors: theme.colorPalette(),
  fonts: theme.fonts(),
  padding: theme.spacingScale(),
  margin: theme.spacingScale(),

  // ...

  plugins: [
    ...theme.components()
  ]
}

A truly "done for you" theme could even provide an entire Tailwind configuration:

// Your Tailwind config:

module.exports = require('tailwindcss-enterprise-theme')()

@adamwathan
Copy link
Member Author

I'm going to explore using the API you suggested + providing an escape helper and see how it goes

Following up on this @inxilpro, I tried it out but ran into a few issues that made me decide to hold off on supporting that simpler API for now.

The main ones:

  • JavaScript objects don't guarantee key order, so you can't guarantee the CSS rules will be output in the same order you build the object. In my testing this only affects numeric keys, but it still makes me a bit uncomfortable because CSS declaration order is important.
  • Objects can't have duplicate keys, which is obvious when you think about JavaScript but not obvious when you are thinking about CSS, where you might have the same selector defined in two places in a specific order for specific reasons, or where you might want to reuse the same media query with different rules inside of it.

I still want to support this syntax eventually, but trying to iron out all the little issues was a dark rabbit hole and I wanted to get a less fancy version out faster.

@adamwathan adamwathan merged commit 9fec0b1 into master Mar 5, 2018
@garygreen
Copy link

garygreen commented Mar 8, 2018

I have to say I'm not too much a fan of this at all.

function ({selector, addUtilities, addComponents}) {
  const components = [
    selector('.btn-blue', {
      'background-color': 'blue',
      'color': 'white',
      'padding': '.5rem 1rem',
      'border-radius': '.25rem',  
    })
    selector('.btn-blue:hover', {
      'background-color': 'darkblue',
    })
  ]

  addComponents(components)
}

This completely goes against what Tailwind promotes which is to re-use a defined set of sizes, colours etc.

The config is overly complicated when it's essentially just adding CSS!

When I envisaged the component system I imagined it would operate much like the @apply rules where you have a base template where you can change the css utilities it uses, in some easy way.

Components is definitely a very useful thing to add to Tailwind, but I just not a fan of this overly complicated implementation. Maybe documentation would clear that up though. Seems very webpack-y (which has never been easy or pleasant to configure!).

@adamwathan
Copy link
Member Author

@garygreen In practice, those values would be configurable and would fallback to sane defaults based on the Tailwind config file.

The main benefit to this system is allowing people to npm install prebuilt components, and customize them as needed to match their config overrides. If you are writing your own components, I think you are likely better off sticking to regular CSS and using @apply to help enforce consistency.

Providing customizable third-party components via CSS can't really be done, so this system is designed to let you do it in JS, since at least JS components can be easily configured.

It's definitely a non-trivial amount of work to build plugins in a robust, customizable, and also "nice-out-of-the-box" way but the goal here is making things nice for the plugin consumer.

If a plugin is designed properly, a user using Tailwind's default configuration should be able to just do this:

plugins: [
  require('tailwindcss-flat-buttons')(),
]

...and have nice-looking button classes available instantly, that all follow Tailwind's default design system.

If someone needs to customize, a plugin should expose all of that as well, but optionally:

plugins: [
  require('tailwindcss-flat-buttons')({
    sizes: {
      default: {
        fontSize: textSizes['base'],
        paddingHorizontal: padding['4'],
        paddingVertical: padding['2'],
      },
      lg: {
        fontSize: textSizes['lg'],
        paddingHorizontal: padding['6'],
        paddingVertical: padding['3'],
      }
    },
    colors: {
      primary: {
        background: colors['indigo'],
        text: colors['white'],
      },
      secondary: {
        background: colors['white'],
        text: colors['indigo-dark'],
      },
    }
  }),
]

I think it's all going to work out, but if you have any better suggestions I'd love to hear them.

@garygreen
Copy link

That looks more like it. Initially I was just overwhelmed by how complex this looks (on the side of component authors) but from the consumer side it looks simpler and makes a lot of sense when you get your head round it.

Tailwind has been great for documentation so I'm sure all of this will be thoroughly explained for both author and consumer.

One thing, what's the difference between rule and atRule? Couldn't rule accept an array of stuff? I'm just thinking atRule seems quite specific when all it's doing is just a block of css? Originally I thought "rule" was applying some kind of conditional stuff, I liked the original name "selector" personally.

@adamwathan
Copy link
Member Author

rule was for creating regular CSS rules, like .bg-blue { background-color: blue; } which accept property declarations, and atRule was for creating at-rules, like @media (...) { ... } which accept an array of regular CSS rules.

That's actually all been changed and improved a lot since this PR though; check out the changes here:

#412

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.

6 participants