mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Add initial support for applying variants and other complex classes
This commit is contained in:
parent
1791768a56
commit
36fc03b16d
348
__tests__/applyComplexClasses.test.js
Normal file
348
__tests__/applyComplexClasses.test.js
Normal file
@ -0,0 +1,348 @@
|
||||
import postcss from 'postcss'
|
||||
import substituteClassApplyAtRules from '../src/lib/substituteClassApplyAtRules'
|
||||
import processPlugins from '../src/util/processPlugins'
|
||||
import resolveConfig from '../src/util/resolveConfig'
|
||||
import corePlugins from '../src/corePlugins'
|
||||
import defaultConfig from '../stubs/defaultConfig.stub.js'
|
||||
|
||||
const resolvedDefaultConfig = resolveConfig([defaultConfig])
|
||||
|
||||
const { utilities: defaultUtilities } = processPlugins(
|
||||
corePlugins(resolvedDefaultConfig),
|
||||
resolvedDefaultConfig
|
||||
)
|
||||
|
||||
function run(input, config = resolvedDefaultConfig, utilities = defaultUtilities) {
|
||||
config.experimental = {
|
||||
applyComplexClasses: true,
|
||||
}
|
||||
return postcss([substituteClassApplyAtRules(config, utilities)]).process(input, {
|
||||
from: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
test('it copies class declarations into itself', () => {
|
||||
const output = '.a { color: red; } .b { color: red; }'
|
||||
|
||||
return run('.a { color: red; } .b { @apply a; }').then(result => {
|
||||
expect(result.css).toEqual(output)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('selectors with invalid characters do not need to be manually escaped', () => {
|
||||
const input = `
|
||||
.a\\:1\\/2 { color: red; }
|
||||
.b { @apply a:1/2; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.a\\:1\\/2 { color: red; }
|
||||
.b { color: red; }
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('it removes important from applied classes by default', () => {
|
||||
const input = `
|
||||
.a { color: red !important; }
|
||||
.b { @apply a; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.a { color: red !important; }
|
||||
.b { color: red; }
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('applied rules can be made !important', () => {
|
||||
const input = `
|
||||
.a { color: red; }
|
||||
.b { @apply a !important; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.a { color: red; }
|
||||
.b { color: red !important; }
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('cssnext custom property sets are preserved', () => {
|
||||
const input = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
.b {
|
||||
@apply a --custom-property-set;
|
||||
}
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
.b {
|
||||
color: red;
|
||||
@apply --custom-property-set;
|
||||
}
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('it fails if the class does not exist', () => {
|
||||
return run('.b { @apply a; }').catch(e => {
|
||||
expect(e).toMatchObject({ name: 'CssSyntaxError' })
|
||||
})
|
||||
})
|
||||
|
||||
test('applying classes that are defined in a media query is supported', () => {
|
||||
const input = `
|
||||
@media (min-width: 300px) {
|
||||
.a { color: blue; }
|
||||
}
|
||||
|
||||
.b {
|
||||
@apply a;
|
||||
}
|
||||
`
|
||||
|
||||
const output = `
|
||||
@media (min-width: 300px) {
|
||||
.a { color: blue; }
|
||||
}
|
||||
@media (min-width: 300px) {
|
||||
.b { color: blue; }
|
||||
}
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toMatchCss(output)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('applying classes that are used in a media query is supported', () => {
|
||||
const input = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@media (min-width: 300px) {
|
||||
.a { color: blue; }
|
||||
}
|
||||
|
||||
.b {
|
||||
@apply a;
|
||||
}
|
||||
`
|
||||
|
||||
const output = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@media (min-width: 300px) {
|
||||
.a { color: blue; }
|
||||
}
|
||||
|
||||
.b {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@media (min-width: 300px) {
|
||||
.b { color: blue; }
|
||||
}
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toMatchCss(output)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('it matches classes that include pseudo-selectors', () => {
|
||||
const input = `
|
||||
.a:hover {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.b {
|
||||
@apply a;
|
||||
}
|
||||
`
|
||||
|
||||
const output = `
|
||||
.a:hover {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.b:hover {
|
||||
color: red;
|
||||
}
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toMatchCss(output)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('it matches classes that have multiple rules', () => {
|
||||
const input = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.b {
|
||||
@apply a;
|
||||
}
|
||||
|
||||
.a {
|
||||
color: blue;
|
||||
}
|
||||
`
|
||||
|
||||
const output = `
|
||||
.a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.b {
|
||||
color: red;
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.a {
|
||||
color: blue;
|
||||
}
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toMatchCss(output)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('you can apply utility classes that do not actually exist as long as they would exist if utilities were being generated', () => {
|
||||
const input = `
|
||||
.foo { @apply mt-4; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.foo { margin-top: 1rem; }
|
||||
`
|
||||
|
||||
return run(input).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('you can apply utility classes without using the given prefix', () => {
|
||||
const input = `
|
||||
.foo { @apply .tw-mt-4 .mb-4; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.foo { margin-top: 1rem; margin-bottom: 1rem; }
|
||||
`
|
||||
|
||||
const config = resolveConfig([
|
||||
{
|
||||
...defaultConfig,
|
||||
prefix: 'tw-',
|
||||
},
|
||||
])
|
||||
|
||||
return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('you can apply utility classes without using the given prefix when using a function for the prefix', () => {
|
||||
const input = `
|
||||
.foo { @apply .tw-mt-4 .mb-4; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.foo { margin-top: 1rem; margin-bottom: 1rem; }
|
||||
`
|
||||
|
||||
const config = resolveConfig([
|
||||
{
|
||||
...defaultConfig,
|
||||
prefix: () => {
|
||||
return 'tw-'
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('you can apply utility classes without specificity prefix even if important (selector) is used', () => {
|
||||
const input = `
|
||||
.foo { @apply .mt-8 .mb-8; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.foo { margin-top: 2rem; margin-bottom: 2rem; }
|
||||
`
|
||||
|
||||
const config = resolveConfig([
|
||||
{
|
||||
...defaultConfig,
|
||||
important: '#app',
|
||||
},
|
||||
])
|
||||
|
||||
return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('you can apply utility classes without using the given prefix even if important (selector) is used', () => {
|
||||
const input = `
|
||||
.foo { @apply .tw-mt-4 .mb-4; }
|
||||
`
|
||||
|
||||
const expected = `
|
||||
.foo { margin-top: 1rem; margin-bottom: 1rem; }
|
||||
`
|
||||
|
||||
const config = resolveConfig([
|
||||
{
|
||||
...defaultConfig,
|
||||
prefix: 'tw-',
|
||||
important: '#app',
|
||||
},
|
||||
])
|
||||
|
||||
return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => {
|
||||
expect(result.css).toEqual(expected)
|
||||
expect(result.warnings().length).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ const featureFlags = {
|
||||
'extendedSpacingScale',
|
||||
'defaultLineHeights',
|
||||
'extendedFontSizeScale',
|
||||
'applyComplexClasses',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
207
src/flagged/applyComplexClasses.js
Normal file
207
src/flagged/applyComplexClasses.js
Normal file
@ -0,0 +1,207 @@
|
||||
import _ from 'lodash'
|
||||
import selectorParser from 'postcss-selector-parser'
|
||||
|
||||
function applyUtility(rule, className, replaceWith) {
|
||||
const selectors = rule.selectors.map(selector => {
|
||||
const processor = selectorParser(selectors => {
|
||||
selectors.walkClasses(c => {
|
||||
if (c.value === className) {
|
||||
c.replaceWith(selectorParser.attribute({ attribute: '__TAILWIND-APPLY-PLACEHOLDER__' }))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// You could argue we should make this replacement at the AST level, but if we believe
|
||||
// the placeholder string is safe from collisions then it is safe to do this is a simple
|
||||
// string replacement, and much, much faster.
|
||||
const processedSelector = processor
|
||||
.processSync(selector)
|
||||
.replace('[__TAILWIND-APPLY-PLACEHOLDER__]', replaceWith)
|
||||
|
||||
return processedSelector
|
||||
})
|
||||
|
||||
const cloned = rule.clone()
|
||||
let current = cloned
|
||||
let parent = rule.parent
|
||||
|
||||
while (parent && parent.type !== 'root') {
|
||||
const parentClone = parent.clone()
|
||||
parentClone.removeAll()
|
||||
parentClone.append(current)
|
||||
current.parent = parentClone
|
||||
current = parentClone
|
||||
parent = parent.parent
|
||||
}
|
||||
|
||||
cloned.selectors = selectors
|
||||
return current
|
||||
}
|
||||
|
||||
function extractUtilityNames(selector) {
|
||||
const processor = selectorParser(selectors => {
|
||||
let classes = []
|
||||
|
||||
selectors.walkClasses(c => {
|
||||
classes.push(c)
|
||||
})
|
||||
|
||||
return classes.map(c => c.value)
|
||||
})
|
||||
|
||||
return processor.transformSync(selector)
|
||||
}
|
||||
|
||||
function buildUtilityMap(css) {
|
||||
let index = 0
|
||||
const utilityMap = {}
|
||||
|
||||
css.walkRules(rule => {
|
||||
const utilityNames = extractUtilityNames(rule.selector)
|
||||
|
||||
utilityNames.forEach(utilityName => {
|
||||
if (utilityMap[utilityName] === undefined) {
|
||||
utilityMap[utilityName] = []
|
||||
}
|
||||
|
||||
utilityMap[utilityName].push({
|
||||
index,
|
||||
utilityName,
|
||||
rule: rule.clone({ parent: rule.parent }),
|
||||
containsApply: hasInject(rule),
|
||||
})
|
||||
index++
|
||||
})
|
||||
})
|
||||
|
||||
return utilityMap
|
||||
}
|
||||
|
||||
function mergeAdjacentRules(initialRule, rulesToInsert) {
|
||||
let previousRule = initialRule
|
||||
|
||||
rulesToInsert.forEach(toInsert => {
|
||||
if (
|
||||
toInsert.type === 'rule' &&
|
||||
previousRule.type === 'rule' &&
|
||||
toInsert.selector === previousRule.selector
|
||||
) {
|
||||
previousRule.append(toInsert.nodes)
|
||||
} else if (
|
||||
toInsert.type === 'atrule' &&
|
||||
previousRule.type === 'atrule' &&
|
||||
toInsert.params === previousRule.params
|
||||
) {
|
||||
const merged = mergeAdjacentRules(
|
||||
previousRule.nodes[previousRule.nodes.length - 1],
|
||||
toInsert.nodes
|
||||
)
|
||||
|
||||
previousRule.append(merged)
|
||||
} else {
|
||||
previousRule = toInsert
|
||||
}
|
||||
|
||||
toInsert.walk(n => {
|
||||
if (n.nodes && n.nodes.length === 0) {
|
||||
n.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return rulesToInsert.filter(r => r.nodes.length > 0)
|
||||
}
|
||||
|
||||
function makeExtractUtilityRules(css) {
|
||||
const utilityMap = buildUtilityMap(css)
|
||||
const orderUtilityMap = Object.fromEntries(
|
||||
Object.entries(utilityMap).flatMap(([utilityName, utilities]) => {
|
||||
return utilities.map(utility => {
|
||||
return [utility.index, utility]
|
||||
})
|
||||
})
|
||||
)
|
||||
return function(utilityNames, rule) {
|
||||
return utilityNames
|
||||
.flatMap(utilityName => {
|
||||
if (utilityMap[utilityName] === undefined) {
|
||||
throw rule.error(
|
||||
`The \`${utilityName}\` utility does not exist. If you're sure that \`${utilityName}\` exists, make sure that any \`@import\` statements are being properly processed before Tailwind CSS sees your CSS, as \`@apply\` can only be used for classes in the same CSS tree.`,
|
||||
{ word: utilityName }
|
||||
)
|
||||
}
|
||||
return utilityMap[utilityName].map(({ index }) => index)
|
||||
})
|
||||
.sort((a, b) => a - b)
|
||||
.map(i => orderUtilityMap[i])
|
||||
}
|
||||
}
|
||||
|
||||
function hasInject(css) {
|
||||
let foundInject = false
|
||||
|
||||
css.walkAtRules('apply', () => {
|
||||
foundInject = true
|
||||
return false
|
||||
})
|
||||
|
||||
return foundInject
|
||||
}
|
||||
|
||||
export default function applyComplexClasses(css) {
|
||||
const extractUtilityRules = makeExtractUtilityRules(css)
|
||||
|
||||
while (hasInject(css)) {
|
||||
css.walkRules(rule => {
|
||||
const injectRules = []
|
||||
|
||||
// Only walk direct children to avoid issues with nesting plugins
|
||||
rule.each(child => {
|
||||
if (child.type === 'atrule' && child.name === 'apply') {
|
||||
injectRules.unshift(child)
|
||||
}
|
||||
})
|
||||
|
||||
injectRules.forEach(inject => {
|
||||
const injectUtilityNames = inject.params.split(' ')
|
||||
const currentUtilityNames = extractUtilityNames(rule.selector)
|
||||
|
||||
if (_.intersection(injectUtilityNames, currentUtilityNames).length > 0) {
|
||||
const currentUtilityName = _.intersection(injectUtilityNames, currentUtilityNames)[0]
|
||||
throw rule.error(
|
||||
`You cannot \`@apply\` the \`${currentUtilityName}\` utility here because it creates a circular dependency.`
|
||||
)
|
||||
}
|
||||
|
||||
// Extract any post-inject declarations and re-insert them after inject rules
|
||||
const afterRule = rule.clone({ raws: {} })
|
||||
afterRule.nodes = afterRule.nodes.slice(rule.index(inject) + 1)
|
||||
rule.nodes = rule.nodes.slice(0, rule.index(inject) + 1)
|
||||
|
||||
// Sort injects to match CSS source order
|
||||
const injects = extractUtilityRules(injectUtilityNames, inject)
|
||||
|
||||
// Get new rules with the utility portion of the selector replaced with the new selector
|
||||
const rulesToInsert = [
|
||||
...injects.map(injectUtility => {
|
||||
return applyUtility(injectUtility.rule, injectUtility.utilityName, rule.selector)
|
||||
}),
|
||||
afterRule,
|
||||
]
|
||||
|
||||
const mergedRules = mergeAdjacentRules(rule, rulesToInsert)
|
||||
|
||||
inject.remove()
|
||||
rule.after(mergedRules)
|
||||
})
|
||||
|
||||
// If the base rule has nothing in it (all injects were pseudo or responsive variants),
|
||||
// remove the rule fuggit.
|
||||
if (rule.nodes.length === 0) {
|
||||
rule.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return css
|
||||
}
|
||||
@ -4,6 +4,9 @@ import escapeClassName from '../util/escapeClassName'
|
||||
import prefixSelector from '../util/prefixSelector'
|
||||
import increaseSpecificity from '../util/increaseSpecificity'
|
||||
|
||||
import { flagEnabled } from '../featureFlags'
|
||||
import applyComplexClasses from '../flagged/applyComplexClasses'
|
||||
|
||||
function buildClassTable(css) {
|
||||
const classTable = {}
|
||||
|
||||
@ -54,6 +57,10 @@ function findClass(classToApply, classTable, onError) {
|
||||
}
|
||||
|
||||
export default function(config, generatedUtilities) {
|
||||
if (flagEnabled(config, 'applyComplexClasses')) {
|
||||
return applyComplexClasses
|
||||
}
|
||||
|
||||
return function(css) {
|
||||
const classLookup = buildClassTable(css)
|
||||
const shadowLookup = buildShadowTable(generatedUtilities)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user