Skip to content

Commit

Permalink
Expose context.getVariants for intellisense (#9505)
Browse files Browse the repository at this point in the history
* add `context.getVariants`

* use `modifier` instead of `label`

* handle `modifySelectors` version

* use reference

* reverse engineer manual format strings if container was touched

* use new positional API for `matchVariant`

* update changelog
  • Loading branch information
RobinMalfait committed Oct 17, 2022
1 parent b7d5a2f commit bc00445
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add aria variants ([#9557](https://github.com/tailwindlabs/tailwindcss/pull/9557))
- Add `data-*` variants ([#9559](https://github.com/tailwindlabs/tailwindcss/pull/9559))
- Upgrade to `postcss-nested` v6.0 ([#9546](https://github.com/tailwindlabs/tailwindcss/pull/9546))
- Expose `context.getVariants` for intellisense ([#9505](https://github.com/tailwindlabs/tailwindcss/pull/9505))

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion src/corePlugins.js
Expand Up @@ -375,7 +375,7 @@ export let variantPlugins = {
check = `(${check})`
}

return `@supports ${check} `
return `@supports ${check}`
},
{ values: theme('supports') ?? {} }
)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/generateRules.js
Expand Up @@ -18,7 +18,7 @@ let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
})

function getClassNameFromSelector(selector) {
export function getClassNameFromSelector(selector) {
return classNameParser.transformSync(selector)
}

Expand Down
189 changes: 165 additions & 24 deletions src/lib/setupContextUtils.js
Expand Up @@ -18,12 +18,21 @@ import { toPath } from '../util/toPath'
import log from '../util/log'
import negateValue from '../util/negateValue'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { generateRules } from './generateRules'
import { generateRules, getClassNameFromSelector } from './generateRules'
import { hasContentChanged } from './cacheInvalidation.js'
import { Offsets } from './offsets.js'
import { flagEnabled } from '../featureFlags.js'
import { finalizeSelector, formatVariantSelector } from '../util/formatVariantSelector'

let MATCH_VARIANT = Symbol()
const VARIANT_TYPES = {
AddVariant: Symbol.for('ADD_VARIANT'),
MatchVariant: Symbol.for('MATCH_VARIANT'),
}

const VARIANT_INFO = {
Base: 1 << 0,
Dynamic: 1 << 1,
}

function prefix(context, selector) {
let prefix = context.tailwindConfig.prefix
Expand Down Expand Up @@ -524,7 +533,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
let result = variantFunction(
Object.assign(
{ modifySelectors, container, separator },
variantFunction[MATCH_VARIANT] && { args, wrap, format }
options.type === VARIANT_TYPES.MatchVariant && { args, wrap, format }
)
)

Expand Down Expand Up @@ -570,33 +579,34 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
for (let [key, value] of Object.entries(options?.values ?? {})) {
api.addVariant(
isSpecial ? `${variant}${key}` : `${variant}-${key}`,
Object.assign(
({ args, container }) =>
variantFn(
value,
modifiersEnabled ? { modifier: args.modifier, container } : { container }
),
{
[MATCH_VARIANT]: true,
}
),
{ ...options, value, id }
)
}

api.addVariant(
variant,
Object.assign(
({ args, container }) =>
variantFn(
args.value,
value,
modifiersEnabled ? { modifier: args.modifier, container } : { container }
),
{
[MATCH_VARIANT]: true,
...options,
value,
id,
type: VARIANT_TYPES.MatchVariant,
variantInfo: VARIANT_INFO.Base,
}
),
{ ...options, id }
)
}

api.addVariant(
variant,
({ args, container }) =>
variantFn(
args.value,
modifiersEnabled ? { modifier: args.modifier, container } : { container }
),
{
...options,
id,
type: VARIANT_TYPES.MatchVariant,
variantInfo: VARIANT_INFO.Dynamic,
}
)
},
}
Expand Down Expand Up @@ -948,6 +958,137 @@ function registerPlugins(plugins, context) {

return output
}

// Generate a list of available variants with meta information of the type of variant.
context.getVariants = function getVariants() {
let result = []
for (let [name, options] of context.variantOptions.entries()) {
if (options.variantInfo === VARIANT_INFO.Base) continue

result.push({
name,
isArbitrary: options.type === Symbol.for('MATCH_VARIANT'),
values: Object.keys(options.values ?? {}),
selectors({ modifier, value } = {}) {
let candidate = '__TAILWIND_PLACEHOLDER__'

let rule = postcss.rule({ selector: `.${candidate}` })
let container = postcss.root({ nodes: [rule.clone()] })

let before = container.toString()

let fns = (context.variantMap.get(name) ?? []).flatMap(([_, fn]) => fn)
let formatStrings = []
for (let fn of fns) {
let localFormatStrings = []

let api = {
args: { modifier, value: options.values?.[value] ?? value },
separator: context.tailwindConfig.separator,
modifySelectors(modifierFunction) {
// Run the modifierFunction over each rule
container.each((rule) => {
if (rule.type !== 'rule') {
return
}

rule.selectors = rule.selectors.map((selector) => {
return modifierFunction({
get className() {
return getClassNameFromSelector(selector)
},
selector,
})
})
})

return container
},
format(str) {
localFormatStrings.push(str)
},
wrap(wrapper) {
localFormatStrings.push(`@${wrapper.name} ${wrapper.params} { & }`)
},
container,
}

let ruleWithVariant = fn(api)
if (localFormatStrings.length > 0) {
formatStrings.push(localFormatStrings)
}

if (Array.isArray(ruleWithVariant)) {
for (let variantFunction of ruleWithVariant) {
localFormatStrings = []
variantFunction(api)
formatStrings.push(localFormatStrings)
}
}
}

// Reverse engineer the result of the `container`
let manualFormatStrings = []
let after = container.toString()

if (before !== after) {
// Figure out all selectors
container.walkRules((rule) => {
let modified = rule.selector

// Rebuild the base selector, this is what plugin authors would do
// as well. E.g.: `${variant}${separator}${className}`.
// However, plugin authors probably also prepend or append certain
// classes, pseudos, ids, ...
let rebuiltBase = selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `${name}${context.tailwindConfig.separator}${classNode.value}`
})
}).processSync(modified)

// Now that we know the original selector, the new selector, and
// the rebuild part in between, we can replace the part that plugin
// authors need to rebuild with `&`, and eventually store it in the
// collectedFormats. Similar to what `format('...')` would do.
//
// E.g.:
// variant: foo
// selector: .markdown > p
// modified (by plugin): .foo .foo\\:markdown > p
// rebuiltBase (internal): .foo\\:markdown > p
// format: .foo &
manualFormatStrings.push(modified.replace(rebuiltBase, '&').replace(candidate, '&'))
})

// Figure out all atrules
container.walkAtRules((atrule) => {
manualFormatStrings.push(`@${atrule.name} (${atrule.params}) { & }`)
})
}

let result = formatStrings.map((formatString) =>
finalizeSelector(formatVariantSelector('&', ...formatString), {
selector: `.${candidate}`,
candidate,
context,
isArbitraryVariant: !(value in (options.values ?? {})),
})
.replace(`.${candidate}`, '&')
.replace('{ & }', '')
.trim()
)

if (manualFormatStrings.length > 0) {
result.push(formatVariantSelector('&', ...manualFormatStrings))
}

return result
},
})
}

return result
}
}

/**
Expand Down
140 changes: 140 additions & 0 deletions tests/getVariants.test.js
@@ -0,0 +1,140 @@
import postcss from 'postcss'
import selectorParser from 'postcss-selector-parser'
import resolveConfig from '../src/public/resolve-config'
import { createContext } from '../src/lib/setupContextUtils'

it('should return a list of variants with meta information about the variant', () => {
let config = {}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

expect(variants).toContainEqual({
name: 'hover',
isArbitrary: false,
values: [],
selectors: expect.any(Function),
})

expect(variants).toContainEqual({
name: 'group',
isArbitrary: true,
values: expect.any(Array),
selectors: expect.any(Function),
})

// `group-hover` now belongs to the `group` variant. The information exposed for the `group`
// variant is all you need.
expect(variants.find((v) => v.name === 'group-hover')).toBeUndefined()
})

it('should provide selectors for simple variants', () => {
let config = {}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'hover')
expect(variant.selectors()).toEqual(['&:hover'])
})

it('should provide selectors for parallel variants', () => {
let config = {}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'marker')
expect(variant.selectors()).toEqual(['& *::marker', '&::marker'])
})

it('should provide selectors for complex matchVariant variants like `group`', () => {
let config = {}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'group')
expect(variant.selectors()).toEqual(['.group &'])
expect(variant.selectors({})).toEqual(['.group &'])
expect(variant.selectors({ value: 'hover' })).toEqual(['.group:hover &'])
expect(variant.selectors({ value: '.foo_&' })).toEqual(['.foo .group &'])
expect(variant.selectors({ modifier: 'foo', value: 'hover' })).toEqual(['.group\\/foo:hover &'])
expect(variant.selectors({ modifier: 'foo', value: '.foo_&' })).toEqual(['.foo .group\\/foo &'])
})

it('should provide selectors for variants with atrules', () => {
let config = {}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'supports')
expect(variant.selectors({ value: 'display:grid' })).toEqual(['@supports (display:grid)'])
expect(variant.selectors({ value: 'aspect-ratio' })).toEqual([
'@supports (aspect-ratio: var(--tw))',
])
})

it('should provide selectors for custom plugins that do a combination of parallel variants with modifiers with arbitrary values and with atrules', () => {
let config = {
plugins: [
function ({ matchVariant }) {
matchVariant('foo', (value, { modifier }) => {
return [
`
@supports (foo: ${modifier}) {
@media (width <= 400px) {
&:hover
}
}
`,
`.${modifier}\\/${value} &:focus`,
]
})
},
],
}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'foo')
expect(variant.selectors({ modifier: 'bar', value: 'baz' })).toEqual([
'@supports (foo: bar) { @media (width <= 400px) { &:hover } }',
'.bar\\/baz &:focus',
])
})

it('should work for plugins that still use the modifySelectors API', () => {
let config = {
plugins: [
function ({ addVariant }) {
addVariant('foo', ({ modifySelectors, container }) => {
// Manually mutating the selector
modifySelectors(({ selector }) => {
return selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `foo:${classNode.value}`
classNode.parent.insertBefore(classNode, selectorParser().astSync(`.foo `))
})
}).processSync(selector)
})

// Manually wrap in supports query
let wrapper = postcss.atRule({ name: 'supports', params: 'display: grid' })
let nodes = container.nodes
container.removeAll()
wrapper.append(nodes)
container.append(wrapper)
})
},
],
}
let context = createContext(resolveConfig(config))

let variants = context.getVariants()

let variant = variants.find((v) => v.name === 'foo')
expect(variant.selectors({})).toEqual(['@supports (display: grid) { .foo .foo\\:& }'])
})

0 comments on commit bc00445

Please sign in to comment.