Add first-class support for "responsive" components and bucket children

This commit is contained in:
Adam Wathan 2020-07-14 14:32:14 -04:00
parent 658250a96d
commit 9959b1f691
8 changed files with 501 additions and 68 deletions

View file

@ -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(
[

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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:

View file

@ -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 = []
function matchesBucket(atRule, bucket) {
return (
atRule.params === bucket ||
(bucket === 'utilities' && (atRule.params === '' || atRule.params === undefined))
)
}
css.walkAtRules('responsive', atRule => {
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('tailwind', atRule => {
if (atRule.params !== 'screens') {
return
}
css.walkAtRules('screens', atRule => {
if (atRule.params !== bucket) {
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')
}
}

View file

@ -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' }),
])
}
}

View file

@ -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))

View file

@ -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',