diff --git a/__tests__/processPlugins.test.js b/__tests__/processPlugins.test.js index 94d369ddec10..3fcdf7ca9405 100644 --- a/__tests__/processPlugins.test.js +++ b/__tests__/processPlugins.test.js @@ -1114,6 +1114,62 @@ test("component declarations can optionally ignore 'prefix' option", () => { `) }) +test('responsive components are generated with the components at-rule argument', () => { + const { components } = processPlugins( + [ + function({ addComponents }) { + addComponents( + { + '.btn-blue': { + backgroundColor: 'blue', + }, + }, + { variants: ['responsive'] } + ) + }, + ], + makeConfig() + ) + + expect(css(components)).toMatchCss(` + @responsive components { + @variants { + .btn-blue { + background-color: blue + } + } + } + `) +}) + +test('components can use the array shorthand to add variants', () => { + const { components } = processPlugins( + [ + function({ addComponents }) { + addComponents( + { + '.btn-blue': { + backgroundColor: 'blue', + }, + }, + ['responsive'] + ) + }, + ], + makeConfig() + ) + + expect(css(components)).toMatchCss(` + @responsive components { + @variants { + .btn-blue { + background-color: blue + } + } + } + `) +}) + test("component declarations are not affected by the 'important' option", () => { const { components } = processPlugins( [ diff --git a/__tests__/responsiveAtRule.test.js b/__tests__/responsiveAtRule.test.js index a59dff31ff45..d979984669ff 100644 --- a/__tests__/responsiveAtRule.test.js +++ b/__tests__/responsiveAtRule.test.js @@ -13,7 +13,7 @@ test('it can generate responsive variants', () => { .chocolate { color: brown; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -55,7 +55,7 @@ test('it can generate responsive variants with a custom separator', () => { .chocolate { color: brown; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -97,7 +97,7 @@ test('it can generate responsive variants when classes have non-standard charact .chocolate-2\\.5 { color: brown; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -144,7 +144,7 @@ test('responsive variants are grouped', () => { .chocolate { color: brown; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -190,7 +190,7 @@ test('it can generate responsive variants for nested at-rules', () => { } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -255,7 +255,7 @@ test('it can generate responsive variants for deeply nested at-rules', () => { } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -320,7 +320,7 @@ test('screen prefix is only applied to the last class in a selector', () => { .banana li * .sandwich #foo > div { color: yellow; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -357,7 +357,7 @@ test('responsive variants are generated for all selectors in a rule', () => { .foo, .bar { color: yellow; } } - @tailwind screens; + @screens utilities; ` const output = ` @@ -394,7 +394,7 @@ test('selectors with no classes cannot be made responsive', () => { div { color: yellow; } } - @tailwind screens; + @screens utilities; ` expect.assertions(1) return run(input, { @@ -417,7 +417,7 @@ test('all selectors in a rule must contain classes', () => { .foo, div { color: yellow; } } - @tailwind screens; + @screens utilities; ` expect.assertions(1) return run(input, { @@ -433,3 +433,59 @@ test('all selectors in a rule must contain classes', () => { expect(e).toMatchObject({ name: 'CssSyntaxError' }) }) }) + +test('responsive components are inserted at @screens components', () => { + const input = ` + @responsive components { + .banana { color: yellow; } + } + + .apple { color: red; } + + @screens components; + + @responsive { + .chocolate { color: brown; } + } + + @screens utilities; + ` + + const output = ` + .banana { color: yellow; } + .apple { color: red; } + @media (min-width: 500px) { + .sm\\:banana { color: yellow; } + } + @media (min-width: 750px) { + .md\\:banana { color: yellow; } + } + @media (min-width: 1000px) { + .lg\\:banana { color: yellow; } + } + .chocolate { color: brown; } + @media (min-width: 500px) { + .sm\\:chocolate { color: brown; } + } + @media (min-width: 750px) { + .md\\:chocolate { color: brown; } + } + @media (min-width: 1000px) { + .lg\\:chocolate { color: brown; } + } + ` + + return run(input, { + theme: { + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + }, + separator: ':', + }).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) diff --git a/__tests__/tailwindAtRule.test.js b/__tests__/tailwindAtRule.test.js new file mode 100644 index 000000000000..60085ae84375 --- /dev/null +++ b/__tests__/tailwindAtRule.test.js @@ -0,0 +1,226 @@ +import postcss from 'postcss' +import plugin from '../src/lib/substituteTailwindAtRules' +import processPlugins from '../src/util/processPlugins' +import config from '../stubs/defaultConfig.stub.js' + +function run(input, opts = config) { + const plugins = [ + function({ addBase, addComponents, addUtilities }) { + addBase({ base: { property: 'test' } }) + addComponents({ '.components': { property: 'test' } }) + addUtilities({ '.utilities': { property: 'test' } }) + }, + ] + return postcss([plugin(opts, processPlugins(plugins, opts))]).process(input, { + from: undefined, + }) +} + +test('tailwind directives are replaced with their underlying CSS rules', () => { + const input = ` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + const output = ` + /* tailwind start base */ + base { property: test } + /* tailwind end base */ + /* tailwind start components */ + .components { property: test } + /* tailwind end components */ + /* tailwind start screens components */ + @screens components; + /* tailwind end screens components */ + /* tailwind start utilities */ + @variants { + .utilities { property: test } + } + /* tailwind end utilities */ + /* tailwind start screens utilities */ + @screens utilities; + /* tailwind end screens utilities */ + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('root-level component classes are not part of the components group', () => { + const input = ` + @tailwind base; + @tailwind components; + .btn { background: blue } + @tailwind utilities; + ` + + const output = ` + /* tailwind start base */ + base { property: test } + /* tailwind end base */ + + /* tailwind start components */ + .components { property: test } + /* tailwind end components */ + + /* tailwind start screens components */ + @screens components; + /* tailwind end screens components */ + + .btn { background: blue } + + /* tailwind start utilities */ + @variants { + .utilities { property: test } + } + /* tailwind end utilities */ + + /* tailwind start screens utilities */ + @screens utilities; + /* tailwind end screens utilities */ + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('nested rules are included in the corresponding bucket', () => { + const input = ` + @tailwind base { + html { font-size: 20px } + } + @tailwind components { + .btn { background: blue } + } + @tailwind utilities { + .tabular-nums { font-variant-numeric: tabular-nums } + } + ` + + const output = ` + /* tailwind start base */ + base { property: test } + html { font-size: 20px } + /* tailwind end base */ + + /* tailwind start components */ + .components { property: test } + .btn { background: blue } + /* tailwind end components */ + + /* tailwind start screens components */ + @screens components; + /* tailwind end screens components */ + + /* tailwind start utilities */ + @variants { + .utilities { property: test } + } + .tabular-nums { font-variant-numeric: tabular-nums } + /* tailwind end utilities */ + + /* tailwind start screens utilities */ + @screens utilities + /* tailwind end screens utilities */ + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('nested responsive component classes have the components argument added automatically', () => { + const input = ` + @tailwind base; + @tailwind components { + @responsive { + .btn { background: blue } + } + } + @tailwind utilities; + ` + + const output = ` + /* tailwind start base */ + base { property: test } + /* tailwind end base */ + + /* tailwind start components */ + .components { property: test } + @responsive components { + .btn { background: blue } + } + /* tailwind end components */ + + /* tailwind start screens components */ + @screens components; + /* tailwind end screens components */ + + /* tailwind start utilities */ + @variants { + .utilities { property: test } + } + /* tailwind end utilities */ + + /* tailwind start screens utilities */ + @screens utilities; + /* tailwind end screens utilities */ + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('nested responsive component classes authored using the variants syntax have the components argument added automatically', () => { + const input = ` + @tailwind base; + @tailwind components { + @variants responsive { + .btn { background: blue } + } + } + @tailwind utilities; + ` + + const output = ` + /* tailwind start base */ + base { property: test } + /* tailwind end base */ + + /* tailwind start components */ + .components { property: test } + @responsive components { + @variants { + .btn { background: blue } + } + } + /* tailwind end components */ + + /* tailwind start screens components */ + @screens components; + /* tailwind end screens components */ + + /* tailwind start utilities */ + @variants { + .utilities { property: test } + } + /* tailwind end utilities */ + + /* tailwind start screens utilities */ + @screens utilities; + /* tailwind end screens utilities */ + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) diff --git a/src/lib/purgeUnusedStyles.js b/src/lib/purgeUnusedStyles.js index 80fb77798497..8599eff95d17 100644 --- a/src/lib/purgeUnusedStyles.js +++ b/src/lib/purgeUnusedStyles.js @@ -8,12 +8,16 @@ import * as emoji from '../cli/emoji' function removeTailwindComments(css) { css.walkComments(comment => { switch (comment.text.trim()) { + case 'tailwind start base': + case 'tailwind end base': case 'tailwind start components': - case 'tailwind start utilities': - case 'tailwind start screens': case 'tailwind end components': + case 'tailwind start screens components': + case 'tailwind end screens components': + case 'tailwind start utilities': case 'tailwind end utilities': - case 'tailwind end screens': + case 'tailwind start screens utilities': + case 'tailwind end screens utilities': comment.remove() break default: @@ -64,11 +68,11 @@ export default function purgeUnusedUtilities(config) { css.walkComments(comment => { switch (comment.text.trim()) { case 'tailwind start utilities': - case 'tailwind start screens': + case 'tailwind start screens utilities': comment.text = 'purgecss end ignore' break case 'tailwind end utilities': - case 'tailwind end screens': + case 'tailwind end screens utilities': comment.text = 'purgecss start ignore' break default: diff --git a/src/lib/substituteResponsiveAtRules.js b/src/lib/substituteResponsiveAtRules.js index 7436d323e251..aa38d337c09c 100644 --- a/src/lib/substituteResponsiveAtRules.js +++ b/src/lib/substituteResponsiveAtRules.js @@ -4,55 +4,69 @@ import cloneNodes from '../util/cloneNodes' import buildMediaQuery from '../util/buildMediaQuery' import buildSelectorVariant from '../util/buildSelectorVariant' -export default function(config) { - return function(css) { - const { - theme: { screens }, - separator, - } = config - const responsiveRules = postcss.root() - const finalRules = [] - - css.walkAtRules('responsive', atRule => { +function matchesBucket(atRule, bucket) { + return ( + atRule.params === bucket || + (bucket === 'utilities' && (atRule.params === '' || atRule.params === undefined)) + ) +} + +function insertResponsiveRules(config, css, bucket) { + const { + theme: { screens }, + separator, + } = config + const responsiveRules = postcss.root() + const finalRules = [] + + css.walkAtRules('responsive', atRule => { + if (matchesBucket(atRule, bucket)) { const nodes = atRule.nodes responsiveRules.append(...cloneNodes(nodes)) atRule.before(nodes) atRule.remove() + } + }) + + _.keys(screens).forEach(screen => { + const mediaQuery = postcss.atRule({ + name: 'media', + params: buildMediaQuery(screens[screen]), }) - _.keys(screens).forEach(screen => { - const mediaQuery = postcss.atRule({ - name: 'media', - params: buildMediaQuery(screens[screen]), + mediaQuery.append( + _.tap(responsiveRules.clone(), clonedRoot => { + clonedRoot.walkRules(rule => { + rule.selectors = _.map(rule.selectors, selector => + buildSelectorVariant(selector, screen, separator, message => { + throw rule.error(message) + }) + ) + }) }) + ) - mediaQuery.append( - _.tap(responsiveRules.clone(), clonedRoot => { - clonedRoot.walkRules(rule => { - rule.selectors = _.map(rule.selectors, selector => - buildSelectorVariant(selector, screen, separator, message => { - throw rule.error(message) - }) - ) - }) - }) - ) + finalRules.push(mediaQuery) + }) - finalRules.push(mediaQuery) - }) + const hasScreenRules = finalRules.some(i => i.nodes.length !== 0) - const hasScreenRules = finalRules.some(i => i.nodes.length !== 0) + css.walkAtRules('screens', atRule => { + if (atRule.params !== bucket) { + return + } - css.walkAtRules('tailwind', atRule => { - if (atRule.params !== 'screens') { - return - } + if (hasScreenRules) { + atRule.before(finalRules) + } - if (hasScreenRules) { - atRule.before(finalRules) - } + atRule.remove() + }) +} - atRule.remove() - }) +export default function(config) { + return function(css) { + insertResponsiveRules(config, css, 'components') + insertResponsiveRules(config, css, 'utilities') } } diff --git a/src/lib/substituteTailwindAtRules.js b/src/lib/substituteTailwindAtRules.js index 1607f388b959..542fff0f10a3 100644 --- a/src/lib/substituteTailwindAtRules.js +++ b/src/lib/substituteTailwindAtRules.js @@ -40,7 +40,54 @@ export default function( } }) - let includesScreensExplicitly = false + css.walkAtRules('tailwind', atRule => { + if (atRule.params === 'screens') { + atRule.name = 'screens' + atRule.params = 'utilities' + } + }) + + let includesComponentsScreensExplicitly = false + let includesUtilitiesScreensExplicitly = false + + css.walkAtRules('screens', atRule => { + if (atRule.params === 'components') { + includesComponentsScreensExplicitly = true + atRule.before(postcss.comment({ text: 'tailwind start screens components' })) + atRule.after(postcss.comment({ text: 'tailwind end screens components' })) + } + + if (atRule.params === 'utilities') { + includesUtilitiesScreensExplicitly = true + atRule.before(postcss.comment({ text: 'tailwind start screens utilities' })) + atRule.after(postcss.comment({ text: 'tailwind end screens utilities' })) + } + }) + + function hasChildren(atRule) { + return atRule.nodes !== undefined && atRule.nodes.length > 0 + } + + function extractChildren(atRule, bucket) { + if (hasChildren(atRule)) { + atRule.walkAtRules('variants', variantsAtRule => { + const params = postcss.list.comma(variantsAtRule.params) + if (params.includes('responsive')) { + variantsAtRule.params = params.filter(p => p !== 'responsive').join(', ') + variantsAtRule.before( + postcss.atRule({ name: 'responsive', nodes: [variantsAtRule.clone()] }) + ) + variantsAtRule.remove() + } + }) + + atRule.walkAtRules('responsive', responsiveAtRule => { + responsiveAtRule.params = bucket + }) + + atRule.before(atRule.nodes) + } + } css.walkAtRules('tailwind', atRule => { if (atRule.params === 'preflight') { @@ -49,36 +96,42 @@ export default function( } if (atRule.params === 'base') { + atRule.before(postcss.comment({ text: 'tailwind start base' })) atRule.before(updateSource(pluginBase, atRule.source)) + extractChildren(atRule, 'base') + atRule.before(postcss.comment({ text: 'tailwind end base' })) atRule.remove() } if (atRule.params === 'components') { atRule.before(postcss.comment({ text: 'tailwind start components' })) atRule.before(updateSource(pluginComponents, atRule.source)) - atRule.after(postcss.comment({ text: 'tailwind end components' })) + extractChildren(atRule, 'components') + atRule.before(postcss.comment({ text: 'tailwind end components' })) + + if (!includesComponentsScreensExplicitly) { + atRule.before(postcss.comment({ text: 'tailwind start screens components' })) + atRule.before(postcss.atRule({ name: 'screens', params: 'components' })) + atRule.before(postcss.comment({ text: 'tailwind end screens components' })) + } + atRule.remove() } if (atRule.params === 'utilities') { atRule.before(postcss.comment({ text: 'tailwind start utilities' })) atRule.before(updateSource(pluginUtilities, atRule.source)) - atRule.after(postcss.comment({ text: 'tailwind end utilities' })) + extractChildren(atRule, 'utilities') + atRule.before(postcss.comment({ text: 'tailwind end utilities' })) atRule.remove() } - - if (atRule.params === 'screens') { - includesScreensExplicitly = true - atRule.before(postcss.comment({ text: 'tailwind start screens' })) - atRule.after(postcss.comment({ text: 'tailwind end screens' })) - } }) - if (!includesScreensExplicitly) { + if (!includesUtilitiesScreensExplicitly) { css.append([ - postcss.comment({ text: 'tailwind start screens' }), - postcss.atRule({ name: 'tailwind', params: 'screens' }), - postcss.comment({ text: 'tailwind end screens' }), + postcss.comment({ text: 'tailwind start screens utilities' }), + postcss.atRule({ name: 'screens', params: 'utilities' }), + postcss.comment({ text: 'tailwind end screens utilities' }), ]) } } diff --git a/src/util/processPlugins.js b/src/util/processPlugins.js index deb11b90e4cf..98263a65ff68 100644 --- a/src/util/processPlugins.js +++ b/src/util/processPlugins.js @@ -111,7 +111,11 @@ export default function(plugins, config) { pluginUtilities.push(wrapWithVariants(styles.nodes, options.variants)) }, addComponents: (components, options) => { - options = Object.assign({ respectPrefix: true }, options) + const defaultOptions = { variants: [], respectPrefix: true } + + options = Array.isArray(options) + ? Object.assign({}, defaultOptions, { variants: options }) + : _.defaults(options, defaultOptions) const styles = postcss.root({ nodes: parseStyles(components) }) @@ -121,7 +125,11 @@ export default function(plugins, config) { } }) - pluginComponents.push(...styles.nodes) + if (options.variants.length > 0) { + pluginComponents.push(wrapWithVariants(styles.nodes, options.variants, 'components')) + } else { + pluginComponents.push(...styles.nodes) + } }, addBase: baseStyles => { pluginBaseStyles.push(...parseStyles(baseStyles)) diff --git a/src/util/wrapWithVariants.js b/src/util/wrapWithVariants.js index 4c1fcf9d3822..3dc727e27319 100644 --- a/src/util/wrapWithVariants.js +++ b/src/util/wrapWithVariants.js @@ -1,7 +1,23 @@ import postcss from 'postcss' import cloneNodes from './cloneNodes' -export default function wrapWithVariants(rules, variants) { +export default function wrapWithVariants(rules, variants, bucket = 'utilities') { + if (bucket === 'components' && variants.includes('responsive')) { + return postcss + .atRule({ + name: 'responsive', + params: 'components', + }) + .append( + postcss + .atRule({ + name: 'variants', + params: variants.filter(v => v !== 'responsive').join(', '), + }) + .append(cloneNodes(Array.isArray(rules) ? rules : [rules])) + ) + } + return postcss .atRule({ name: 'variants',