Add support for matching multiple utility definitions for one candidate (#14231)

Currently if a plugin adds a utility called `duration` it will take
precedence over the built-in utilities — or any utilities with the same
name in previously included plugins. However, in v3, we emitted matches
from _all_ plugins where possible.

Take this plugin for example which adds utilities for
`animation-duration` via the `duration-*` class:

```ts
import plugin from 'tailwindcss/plugin'

export default plugin(
  function ({ matchUtilities, theme }) {
    matchUtilities(
      { duration: (value) => ({ animationDuration: value }) },
      { values: theme("animationDuration") },
    )
  },
  {
    theme: {
      extend: {
        animationDuration: ({ theme }) => ({
          ...theme("transitionDuration"),
        }),
      }
    },
  }
)
```

Before this PR this plugin's `duration` utility would override the
built-in `duration` utility so you'd get this for a class like
`duration-3500`:
```css
.duration-3000 {
  animation-duration: 3500ms;
}
```

Now, after this PR, we'll emit rules for `transition-duration`
(Tailwind's built-in `duration-*` utility) and `animation-duration`
(from the above plugin) and you'll get this instead:
```css
.duration-3000 {
  transition-duration: 3500ms;
}

.duration-3000 {
  animation-duration: 3500ms;
}
```

These are output as separate rules to ensure that they can all be sorted
appropriately against other utilities.

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Jordan Pittman 2024-08-22 10:22:12 -04:00 committed by GitHub
parent bc88958855
commit cc228fbfc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1063 additions and 754 deletions

View File

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `tailwindcss/colors` and `tailwindcss/defaultTheme` exports for use with plugins ([#14221](https://github.com/tailwindlabs/tailwindcss/pull/14221))
- Add support for the `@tailwindcss/typography` and `@tailwindcss/forms` plugins ([#14221](https://github.com/tailwindlabs/tailwindcss/pull/14221))
- Add support for the `theme()` function in CSS and class names ([#14177](https://github.com/tailwindlabs/tailwindcss/pull/14177))
- Add support for matching multiple utility definitions for one candidate ([#14231](https://github.com/tailwindlabs/tailwindcss/pull/14231))
### Fixed

View File

@ -1,7 +1,7 @@
import { candidate, css, html, json, test } from '../utils'
test(
'builds the typography plugin utilities',
'builds the `@tailwindcss/typography` plugin utilities',
{
fs: {
'package.json': json`
@ -40,7 +40,7 @@ test(
)
test(
'builds the forms plugin utilities',
'builds the `@tailwindcss/forms` plugin utilities',
{
fs: {
'package.json': json`
@ -76,3 +76,39 @@ test(
])
},
)
test(
'builds the `tailwindcss-animate` plugin utilities',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss-animate": "^1.0.7",
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="animate-in fade-in zoom-in duration-350"></div>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
candidate`animate-in`,
candidate`fade-in`,
candidate`zoom-in`,
candidate`duration-350`,
'transition-duration: 350ms',
'animation-duration: 350ms',
])
},
)

View File

@ -488,7 +488,6 @@ exports[`getClassList 1`] = `
"bg-gradient-to-tl",
"bg-gradient-to-tr",
"bg-inherit",
"bg-inherit",
"bg-left",
"bg-left-bottom",
"bg-left-top",
@ -517,7 +516,6 @@ exports[`getClassList 1`] = `
"bg-space",
"bg-top",
"bg-transparent",
"bg-transparent",
"block",
"blur-none",
"border",

View File

@ -15,6 +15,6 @@ const designSystem = buildDesignSystem(new Theme())
bench('parseCandidate', () => {
for (let candidate of candidates) {
parseCandidate(candidate, designSystem)
Array.from(parseCandidate(candidate, designSystem))
}
})

File diff suppressed because it is too large Load Diff

View File

@ -179,6 +179,7 @@ export type Candidate =
modifier: ArbitraryModifier | NamedModifier | null
variants: Variant[]
important: boolean
raw: string
}
/**
@ -195,6 +196,7 @@ export type Candidate =
variants: Variant[]
negative: boolean
important: boolean
raw: string
}
/**
@ -214,9 +216,10 @@ export type Candidate =
variants: Variant[]
negative: boolean
important: boolean
raw: string
}
export function parseCandidate(input: string, designSystem: DesignSystem): Candidate | null {
export function* parseCandidate(input: string, designSystem: DesignSystem): Iterable<Candidate> {
// hover:focus:underline
// ^^^^^ ^^^^^^ -> Variants
// ^^^^^^^^^ -> Base
@ -232,7 +235,7 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
for (let i = rawVariants.length - 1; i >= 0; --i) {
let parsedVariant = designSystem.parseVariant(rawVariants[i])
if (parsedVariant === null) return null
if (parsedVariant === null) return
parsedCandidateVariants.push(parsedVariant)
}
@ -263,12 +266,13 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// Check for an exact match of a static utility first as long as it does not
// look like an arbitrary value.
if (designSystem.utilities.has(base, 'static') && !base.includes('[')) {
return {
yield {
kind: 'static',
root: base,
variants: parsedCandidateVariants,
negative,
important,
raw: input,
}
}
@ -288,12 +292,12 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// E.g.:
//
// - `bg-red-500/50/50`
if (additionalModifier) return null
if (additionalModifier) return
// Arbitrary properties
if (baseWithoutModifier[0] === '[') {
// Arbitrary properties should end with a `]`.
if (baseWithoutModifier[baseWithoutModifier.length - 1] !== ']') return null
if (baseWithoutModifier[baseWithoutModifier.length - 1] !== ']') return
// The property part of the arbitrary property can only start with a-z
// lowercase or a dash `-` in case of vendor prefixes such as `-webkit-`
@ -302,7 +306,7 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// Otherwise, it is an invalid candidate, and skip continue parsing.
let charCode = baseWithoutModifier.charCodeAt(1)
if (charCode !== DASH && !(charCode >= LOWER_A && charCode <= LOWER_Z)) {
return null
return
}
baseWithoutModifier = baseWithoutModifier.slice(1, -1)
@ -315,28 +319,27 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// also verify that the colon is not the first or last character in the
// candidate, because that would make it invalid as well.
let idx = baseWithoutModifier.indexOf(':')
if (idx === -1 || idx === 0 || idx === baseWithoutModifier.length - 1) return null
if (idx === -1 || idx === 0 || idx === baseWithoutModifier.length - 1) return
let property = baseWithoutModifier.slice(0, idx)
let value = decodeArbitraryValue(baseWithoutModifier.slice(idx + 1))
return {
yield {
kind: 'arbitrary',
property,
value,
modifier: modifierSegment === null ? null : parseModifier(modifierSegment),
variants: parsedCandidateVariants,
important,
raw: input,
}
return
}
// The root of the utility, e.g.: `bg-red-500`
// ^^
let root: string | null = null
// The value of the utility, e.g.: `bg-red-500`
// ^^^^^^^
let value: string | null = null
// The different "versions"" of a candidate that are utilities
// e.g. `['bg', 'red-500']` and `['bg-red', '500']`
let roots: Iterable<Root>
// If the base of the utility ends with a `]`, then we know it's an arbitrary
// value. This also means that everything before the `[…]` part should be the
@ -355,111 +358,111 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// ```
if (baseWithoutModifier[baseWithoutModifier.length - 1] === ']') {
let idx = baseWithoutModifier.indexOf('-[')
if (idx === -1) return null
if (idx === -1) return
root = baseWithoutModifier.slice(0, idx)
let root = baseWithoutModifier.slice(0, idx)
// The root of the utility should exist as-is in the utilities map. If not,
// it's an invalid utility and we can skip continue parsing.
if (!designSystem.utilities.has(root, 'functional')) return null
if (!designSystem.utilities.has(root, 'functional')) return
value = baseWithoutModifier.slice(idx + 1)
let value = baseWithoutModifier.slice(idx + 1)
roots = [[root, value]]
}
// Not an arbitrary value
else {
;[root, value] = findRoot(baseWithoutModifier, (root: string) => {
roots = findRoots(baseWithoutModifier, (root: string) => {
return designSystem.utilities.has(root, 'functional')
})
}
// If there's no root, the candidate isn't a valid class and can be discarded.
if (root === null) return null
for (let [root, value] of roots) {
let candidate: Candidate = {
kind: 'functional',
root,
modifier: modifierSegment === null ? null : parseModifier(modifierSegment),
value: null,
variants: parsedCandidateVariants,
negative,
important,
raw: input,
}
// If the leftover value is an empty string, it means that the value is an
// invalid named value, e.g.: `bg-`. This makes the candidate invalid and we
// can skip any further parsing.
if (value === '') return null
if (value === null) {
yield candidate
continue
}
let candidate: Candidate = {
kind: 'functional',
root,
modifier: modifierSegment === null ? null : parseModifier(modifierSegment),
value: null,
variants: parsedCandidateVariants,
negative,
important,
}
{
let startArbitraryIdx = value.indexOf('[')
let valueIsArbitrary = startArbitraryIdx !== -1
if (value === null) return candidate
if (valueIsArbitrary) {
let arbitraryValue = value.slice(startArbitraryIdx + 1, -1)
{
let startArbitraryIdx = value.indexOf('[')
let valueIsArbitrary = startArbitraryIdx !== -1
// Extract an explicit typehint if present, e.g. `bg-[color:var(--my-var)])`
let typehint = ''
for (let i = 0; i < arbitraryValue.length; i++) {
let code = arbitraryValue.charCodeAt(i)
if (valueIsArbitrary) {
let arbitraryValue = value.slice(startArbitraryIdx + 1, -1)
// If we hit a ":", we're at the end of a typehint.
if (code === COLON) {
typehint = arbitraryValue.slice(0, i)
arbitraryValue = arbitraryValue.slice(i + 1)
break
}
// Extract an explicit typehint if present, e.g. `bg-[color:var(--my-var)])`
let typehint = ''
for (let i = 0; i < arbitraryValue.length; i++) {
let code = arbitraryValue.charCodeAt(i)
// Keep iterating as long as we've only seen valid typehint characters.
if (code === DASH || (code >= LOWER_A && code <= LOWER_Z)) {
continue
}
// If we hit a ":", we're at the end of a typehint.
if (code === COLON) {
typehint = arbitraryValue.slice(0, i)
arbitraryValue = arbitraryValue.slice(i + 1)
// If we see any other character, there's no typehint so break early.
break
}
// Keep iterating as long as we've only seen valid typehint characters.
if (code === DASH || (code >= LOWER_A && code <= LOWER_Z)) {
continue
// If an arbitrary value looks like a CSS variable, we automatically wrap
// it with `var(...)`.
//
// But since some CSS properties accept a `<dashed-ident>` as a value
// directly (e.g. `scroll-timeline-name`), we also store the original
// value in case the utility matcher is interested in it without
// `var(...)`.
let dashedIdent: string | null = null
if (arbitraryValue[0] === '-' && arbitraryValue[1] === '-') {
dashedIdent = arbitraryValue
arbitraryValue = `var(${arbitraryValue})`
} else {
arbitraryValue = decodeArbitraryValue(arbitraryValue)
}
// If we see any other character, there's no typehint so break early.
break
}
// If an arbitrary value looks like a CSS variable, we automatically wrap
// it with `var(...)`.
//
// But since some CSS properties accept a `<dashed-ident>` as a value
// directly (e.g. `scroll-timeline-name`), we also store the original
// value in case the utility matcher is interested in it without
// `var(...)`.
let dashedIdent: string | null = null
if (arbitraryValue[0] === '-' && arbitraryValue[1] === '-') {
dashedIdent = arbitraryValue
arbitraryValue = `var(${arbitraryValue})`
candidate.value = {
kind: 'arbitrary',
dataType: typehint || null,
value: arbitraryValue,
dashedIdent,
}
} else {
arbitraryValue = decodeArbitraryValue(arbitraryValue)
}
// Some utilities support fractions as values, e.g. `w-1/2`. Since it's
// ambiguous whether the slash signals a modifier or not, we store the
// fraction separately in case the utility matcher is interested in it.
let fraction =
modifierSegment === null || candidate.modifier?.kind === 'arbitrary'
? null
: `${value}/${modifierSegment}`
candidate.value = {
kind: 'arbitrary',
dataType: typehint || null,
value: arbitraryValue,
dashedIdent,
}
} else {
// Some utilities support fractions as values, e.g. `w-1/2`. Since it's
// ambiguous whether the slash signals a modifier or not, we store the
// fraction separately in case the utility matcher is interested in it.
let fraction =
modifierSegment === null || candidate.modifier?.kind === 'arbitrary'
? null
: `${value.slice(value.lastIndexOf('-') + 1)}/${modifierSegment}`
candidate.value = {
kind: 'named',
value,
fraction,
candidate.value = {
kind: 'named',
value,
fraction,
}
}
}
}
return candidate
yield candidate
}
}
function parseModifier(modifier: string): CandidateModifier {
@ -548,67 +551,65 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// - `group-hover/foo/bar`
if (additionalModifier) return null
let [root, value] = findRoot(variantWithoutModifier, (root) => {
let roots = findRoots(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)
})
// Variant is invalid, therefore the candidate is invalid and we can skip
// continue parsing it.
if (root === null) return null
for (let [root, value] of roots) {
switch (designSystem.variants.kind(root)) {
case 'static': {
// Static variants do not have a value
if (value !== null) return null
switch (designSystem.variants.kind(root)) {
case 'static': {
// Static variants do not have a value
if (value !== null) return null
// Static variants do not have a modifier
if (modifier !== null) return null
// Static variants do not have a modifier
if (modifier !== null) return null
return {
kind: 'static',
root,
compounds: designSystem.variants.compounds(root),
}
}
case 'functional': {
if (value === null) return null
if (value[0] === '[' && value[value.length - 1] === ']') {
return {
kind: 'functional',
kind: 'static',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: {
kind: 'arbitrary',
value: decodeArbitraryValue(value.slice(1, -1)),
},
compounds: designSystem.variants.compounds(root),
}
}
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: { kind: 'named', value },
compounds: designSystem.variants.compounds(root),
case 'functional': {
if (value === null) return null
if (value[0] === '[' && value[value.length - 1] === ']') {
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: {
kind: 'arbitrary',
value: decodeArbitraryValue(value.slice(1, -1)),
},
compounds: designSystem.variants.compounds(root),
}
}
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: { kind: 'named', value },
compounds: designSystem.variants.compounds(root),
}
}
}
case 'compound': {
if (value === null) return null
case 'compound': {
if (value === null) return null
let subVariant = designSystem.parseVariant(value)
if (subVariant === null) return null
if (subVariant.compounds === false) return null
let subVariant = designSystem.parseVariant(value)
if (subVariant === null) return null
if (subVariant.compounds === false) return null
return {
kind: 'compound',
root,
modifier: modifier === null ? null : { kind: 'named', value: modifier },
variant: subVariant,
compounds: designSystem.variants.compounds(root),
return {
kind: 'compound',
root,
modifier: modifier === null ? null : { kind: 'named', value: modifier },
variant: subVariant,
compounds: designSystem.variants.compounds(root),
}
}
}
}
@ -617,12 +618,21 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
return null
}
function findRoot(
input: string,
exists: (input: string) => boolean,
): [string | null, string | null] {
type Root = [
// The root of the utility, e.g.: `bg-red-500`
// ^^
root: string,
// The value of the utility, e.g.: `bg-red-500`
// ^^^^^^^
value: string | null,
]
function* findRoots(input: string, exists: (input: string) => boolean): Iterable<Root> {
// If there is an exact match, then that's the root.
if (exists(input)) return [input, null]
if (exists(input)) {
yield [input, null]
}
// Otherwise test every permutation of the input by iteratively removing
// everything after the last dash.
@ -631,10 +641,9 @@ function findRoot(
// Variants starting with `@` are special because they don't need a `-`
// after the `@` (E.g.: `@-lg` should be written as `@lg`).
if (input[0] === '@' && exists('@')) {
return ['@', input.slice(1)]
yield ['@', input.slice(1)]
}
return [null, null]
return
}
// Determine the root and value by testing permutations of the incoming input.
@ -648,11 +657,16 @@ function findRoot(
let maybeRoot = input.slice(0, idx)
if (exists(maybeRoot)) {
return [maybeRoot, input.slice(idx + 1)]
let root: Root = [maybeRoot, input.slice(idx + 1)]
// If the leftover value is an empty string, it means that the value is an
// invalid named value, e.g.: `bg-`. This makes the candidate invalid and we
// can skip any further parsing.
if (root[1] === '') break
yield root
}
idx = input.lastIndexOf('-', idx - 1)
} while (idx > 0)
return [null, null]
}

View File

@ -1,8 +1,8 @@
import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast'
import { decl, rule, walk, WalkAction, type AstNode, type Rule } from './ast'
import { type Candidate, type Variant } from './candidate'
import { type DesignSystem } from './design-system'
import GLOBAL_PROPERTY_ORDER from './property-order'
import { asColor } from './utilities'
import { asColor, type Utility } from './utilities'
import { compare } from './utils/compare'
import { escape } from './utils/escape'
import type { Variants } from './variants'
@ -17,16 +17,17 @@ export function compileCandidates(
{ properties: number[]; variants: bigint; candidate: string }
>()
let astNodes: AstNode[] = []
let candidates = new Map<Candidate, string>()
let matches = new Map<string, Candidate[]>()
// Parse candidates and variants
for (let rawCandidate of rawCandidates) {
let candidate = designSystem.parseCandidate(rawCandidate)
if (candidate === null) {
let candidates = designSystem.parseCandidate(rawCandidate)
if (candidates.length === 0) {
onInvalidCandidate?.(rawCandidate)
continue // Bail, invalid candidate
}
candidates.set(candidate, rawCandidate)
matches.set(rawCandidate, candidates)
}
// Sort the variants
@ -35,29 +36,36 @@ export function compileCandidates(
})
// Create the AST
next: for (let [candidate, rawCandidate] of candidates) {
let astNode = designSystem.compileAstNodes(rawCandidate)
if (astNode === null) {
for (let [rawCandidate, candidates] of matches) {
let found = false
for (let candidate of candidates) {
let rules = designSystem.compileAstNodes(candidate)
if (rules.length === 0) continue
found = true
for (let { node, propertySort } of rules) {
// Track the variant order which is a number with each bit representing a
// variant. This allows us to sort the rules based on the order of
// variants used.
let variantOrder = 0n
for (let variant of candidate.variants) {
variantOrder |= 1n << BigInt(variants.indexOf(variant))
}
nodeSorting.set(node, {
properties: propertySort,
variants: variantOrder,
candidate: rawCandidate,
})
astNodes.push(node)
}
}
if (!found) {
onInvalidCandidate?.(rawCandidate)
continue next
}
let { node, propertySort } = astNode
// Track the variant order which is a number with each bit representing a
// variant. This allows us to sort the rules based on the order of
// variants used.
let variantOrder = 0n
for (let variant of candidate.variants) {
variantOrder |= 1n << BigInt(variants.indexOf(variant))
}
nodeSorting.set(node, {
properties: propertySort,
variants: variantOrder,
candidate: rawCandidate,
})
astNodes.push(node)
}
astNodes.sort((a, z) => {
@ -99,39 +107,46 @@ export function compileCandidates(
}
}
export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem) {
let candidate = designSystem.parseCandidate(rawCandidate)
if (candidate === null) return null
export function compileAstNodes(candidate: Candidate, designSystem: DesignSystem) {
let asts = compileBaseUtility(candidate, designSystem)
if (asts.length === 0) return []
let nodes = compileBaseUtility(candidate, designSystem)
let rules: {
node: AstNode
propertySort: number[]
}[] = []
if (!nodes) return null
let selector = `.${escape(candidate.raw)}`
let propertySort = getPropertySort(nodes)
for (let nodes of asts) {
let propertySort = getPropertySort(nodes)
if (candidate.important) {
applyImportant(nodes)
if (candidate.important) {
applyImportant(nodes)
}
let node: Rule = {
kind: 'rule',
selector,
nodes,
}
for (let variant of candidate.variants) {
let result = applyVariant(node, variant, designSystem.variants)
// When the variant results in `null`, it means that the variant cannot be
// applied to the rule. Discard the candidate and continue to the next
// one.
if (result === null) return []
}
rules.push({
node,
propertySort,
})
}
let node: Rule = {
kind: 'rule',
selector: `.${escape(rawCandidate)}`,
nodes,
}
for (let variant of candidate.variants) {
let result = applyVariant(node, variant, designSystem.variants)
// When the variant results in `null`, it means that the variant cannot be
// applied to the rule. Discard the candidate and continue to the next
// one.
if (result === null) return null
}
return {
node,
propertySort,
}
return rules
}
export function applyVariant(
@ -208,6 +223,11 @@ export function applyVariant(
if (result === null) return null
}
function isFallbackUtility(utility: Utility) {
let types = utility.options?.types ?? []
return types.length > 1 && types.includes('any')
}
function compileBaseUtility(candidate: Candidate, designSystem: DesignSystem) {
if (candidate.kind === 'arbitrary') {
let value: string | null = candidate.value
@ -218,24 +238,38 @@ function compileBaseUtility(candidate: Candidate, designSystem: DesignSystem) {
value = asColor(value, candidate.modifier, designSystem.theme)
}
if (value === null) return
if (value === null) return []
return [decl(candidate.property, value)]
return [[decl(candidate.property, value)]]
}
let utilities = designSystem.utilities.get(candidate.root) ?? []
for (let i = utilities.length - 1; i >= 0; i--) {
let utility = utilities[i]
let asts: AstNode[][] = []
if (candidate.kind !== utility.kind) continue
let normalUtilities = utilities.filter((u) => !isFallbackUtility(u))
for (let utility of normalUtilities) {
if (utility.kind !== candidate.kind) continue
let compiledNodes = utility.compileFn(candidate)
if (compiledNodes === null) return null
if (compiledNodes) return compiledNodes
if (compiledNodes === undefined) continue
if (compiledNodes === null) return asts
asts.push(compiledNodes)
}
return null
if (asts.length > 0) return asts
let fallbackUtilities = utilities.filter((u) => isFallbackUtility(u))
for (let utility of fallbackUtilities) {
if (utility.kind !== candidate.kind) continue
let compiledNodes = utility.compileFn(candidate)
if (compiledNodes === undefined) continue
if (compiledNodes === null) return asts
asts.push(compiledNodes)
}
return asts
}
function applyImportant(ast: AstNode[]): void {

View File

@ -1,5 +1,5 @@
import { toCss } from './ast'
import { parseCandidate, parseVariant } from './candidate'
import { parseCandidate, parseVariant, type Candidate } from './candidate'
import { compileAstNodes, compileCandidates } from './compile'
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
import { getClassOrder } from './sort'
@ -18,9 +18,9 @@ export type DesignSystem = {
getClassList(): ClassEntry[]
getVariants(): VariantEntry[]
parseCandidate(candidate: string): ReturnType<typeof parseCandidate>
parseCandidate(candidate: string): Candidate[]
parseVariant(variant: string): ReturnType<typeof parseVariant>
compileAstNodes(candidate: string): ReturnType<typeof compileAstNodes>
compileAstNodes(candidate: Candidate): ReturnType<typeof compileAstNodes>
getUsedVariants(): ReturnType<typeof parseVariant>[]
}
@ -30,8 +30,12 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
let variants = createVariants(theme)
let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem))
let parsedCandidates = new DefaultMap((candidate) => parseCandidate(candidate, designSystem))
let compiledAstNodes = new DefaultMap((candidate) => compileAstNodes(candidate, designSystem))
let parsedCandidates = new DefaultMap((candidate) =>
Array.from(parseCandidate(candidate, designSystem)),
)
let compiledAstNodes = new DefaultMap<Candidate>((candidate) =>
compileAstNodes(candidate, designSystem),
)
let designSystem: DesignSystem = {
theme,
@ -69,7 +73,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
parseVariant(variant: string) {
return parsedVariants.get(variant)
},
compileAstNodes(candidate: string) {
compileAstNodes(candidate: Candidate) {
return compiledAstNodes.get(candidate)
},
getUsedVariants() {

View File

@ -814,6 +814,58 @@ describe('theme', async () => {
expect(fn).toHaveBeenCalledWith('magenta') // Not present in CSS or resolved config
expect(fn).toHaveBeenCalledWith({}) // Present in the resolved config
})
test('Candidates can match multiple utility definitions', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
`
let { build } = await compile(input, {
loadPlugin: async () => {
return plugin(({ addUtilities, matchUtilities }) => {
addUtilities({
'.foo-bar': {
color: 'red',
},
})
matchUtilities(
{
foo: (value) => ({
'--my-prop': value,
}),
},
{
values: {
bar: 'bar-valuer',
baz: 'bar-valuer',
},
},
)
addUtilities({
'.foo-bar': {
backgroundColor: 'red',
},
})
})
},
})
expect(build(['foo-bar'])).toMatchInlineSnapshot(`
".foo-bar {
background-color: red;
}
.foo-bar {
color: red;
}
.foo-bar {
--my-prop: bar-valuer;
}
"
`)
})
})
describe('addUtilities()', () => {

View File

@ -1,6 +1,6 @@
import { substituteAtApply } from './apply'
import { decl, rule, type AstNode } from './ast'
import type { NamedUtilityValue } from './candidate'
import type { Candidate, NamedUtilityValue } from './candidate'
import { createCompatConfig } from './compat/config/create-compat-config'
import { resolveConfig } from './compat/config/resolve-config'
import type { UserConfig } from './compat/config/types'
@ -157,7 +157,7 @@ function buildPluginApi(
)
}
designSystem.utilities.functional(name, (candidate) => {
function compileFn(candidate: Extract<Candidate, { kind: 'functional' }>) {
// A negative utility was provided but is unsupported
if (!options?.supportsNegativeValues && candidate.negative) return
@ -256,6 +256,10 @@ function buildPluginApi(
let ast = objectToAst(fn(value, { modifier }))
substituteAtApply(ast, designSystem)
return ast
}
designSystem.utilities.functional(name, compileFn, {
types,
})
}
},

View File

@ -8109,13 +8109,13 @@ test('rounded-s', async () => {
}
.rounded-s-full {
border-start-start-radius: 3.40282e38px;
border-end-start-radius: 3.40282e38px;
border-start-start-radius: var(--radius-full, 9999px);
border-end-start-radius: var(--radius-full, 9999px);
}
.rounded-s-none {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-start-radius: var(--radius-none, 0px);
border-end-start-radius: var(--radius-none, 0px);
}
.rounded-s-sm {
@ -8172,13 +8172,13 @@ test('rounded-e', async () => {
}
.rounded-e-full {
border-start-end-radius: 3.40282e38px;
border-end-end-radius: 3.40282e38px;
border-start-end-radius: var(--radius-full, 9999px);
border-end-end-radius: var(--radius-full, 9999px);
}
.rounded-e-none {
border-start-end-radius: 0;
border-end-end-radius: 0;
border-start-end-radius: var(--radius-none, 0px);
border-end-end-radius: var(--radius-none, 0px);
}
.rounded-e-sm {
@ -8237,11 +8237,15 @@ test('rounded-t', async () => {
.rounded-t-full {
border-top-left-radius: 3.40282e38px;
border-top-right-radius: 3.40282e38px;
border-top-left-radius: var(--radius-full, 9999px);
border-top-right-radius: var(--radius-full, 9999px);
}
.rounded-t-none {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top-left-radius: var(--radius-none, 0px);
border-top-right-radius: var(--radius-none, 0px);
}
.rounded-t-sm {
@ -8300,11 +8304,15 @@ test('rounded-r', async () => {
.rounded-r-full {
border-top-right-radius: 3.40282e38px;
border-bottom-right-radius: 3.40282e38px;
border-top-right-radius: var(--radius-full, 9999px);
border-bottom-right-radius: var(--radius-full, 9999px);
}
.rounded-r-none {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-right-radius: var(--radius-none, 0px);
border-bottom-right-radius: var(--radius-none, 0px);
}
.rounded-r-sm {
@ -8363,11 +8371,15 @@ test('rounded-b', async () => {
.rounded-b-full {
border-bottom-right-radius: 3.40282e38px;
border-bottom-left-radius: 3.40282e38px;
border-bottom-right-radius: var(--radius-full, 9999px);
border-bottom-left-radius: var(--radius-full, 9999px);
}
.rounded-b-none {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--radius-none, 0px);
border-bottom-left-radius: var(--radius-none, 0px);
}
.rounded-b-sm {
@ -8426,11 +8438,15 @@ test('rounded-l', async () => {
.rounded-l-full {
border-top-left-radius: 3.40282e38px;
border-bottom-left-radius: 3.40282e38px;
border-top-left-radius: var(--radius-full, 9999px);
border-bottom-left-radius: var(--radius-full, 9999px);
}
.rounded-l-none {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-left-radius: var(--radius-none, 0px);
border-bottom-left-radius: var(--radius-none, 0px);
}
.rounded-l-sm {
@ -8485,11 +8501,11 @@ test('rounded-ss', async () => {
}
.rounded-ss-full {
border-start-start-radius: 3.40282e38px;
border-start-start-radius: var(--radius-full, 9999px);
}
.rounded-ss-none {
border-start-start-radius: 0;
border-start-start-radius: var(--radius-none, 0px);
}
.rounded-ss-sm {
@ -8543,11 +8559,11 @@ test('rounded-se', async () => {
}
.rounded-se-full {
border-start-end-radius: 3.40282e38px;
border-start-end-radius: var(--radius-full, 9999px);
}
.rounded-se-none {
border-start-end-radius: 0;
border-start-end-radius: var(--radius-none, 0px);
}
.rounded-se-sm {
@ -8601,11 +8617,11 @@ test('rounded-ee', async () => {
}
.rounded-ee-full {
border-end-end-radius: 3.40282e38px;
border-end-end-radius: var(--radius-full, 9999px);
}
.rounded-ee-none {
border-end-end-radius: 0;
border-end-end-radius: var(--radius-none, 0px);
}
.rounded-ee-sm {
@ -8659,11 +8675,11 @@ test('rounded-es', async () => {
}
.rounded-es-full {
border-end-start-radius: 3.40282e38px;
border-end-start-radius: var(--radius-full, 9999px);
}
.rounded-es-none {
border-end-start-radius: 0;
border-end-start-radius: var(--radius-none, 0px);
}
.rounded-es-sm {
@ -8718,10 +8734,12 @@ test('rounded-tl', async () => {
.rounded-tl-full {
border-top-left-radius: 3.40282e38px;
border-top-left-radius: var(--radius-full, 9999px);
}
.rounded-tl-none {
border-top-left-radius: 0;
border-top-left-radius: var(--radius-none, 0px);
}
.rounded-tl-sm {
@ -8776,10 +8794,12 @@ test('rounded-tr', async () => {
.rounded-tr-full {
border-top-right-radius: 3.40282e38px;
border-top-right-radius: var(--radius-full, 9999px);
}
.rounded-tr-none {
border-top-right-radius: 0;
border-top-right-radius: var(--radius-none, 0px);
}
.rounded-tr-sm {
@ -8834,10 +8854,12 @@ test('rounded-br', async () => {
.rounded-br-full {
border-bottom-right-radius: 3.40282e38px;
border-bottom-right-radius: var(--radius-full, 9999px);
}
.rounded-br-none {
border-bottom-right-radius: 0;
border-bottom-right-radius: var(--radius-none, 0px);
}
.rounded-br-sm {
@ -8892,10 +8914,12 @@ test('rounded-bl', async () => {
.rounded-bl-full {
border-bottom-left-radius: 3.40282e38px;
border-bottom-left-radius: var(--radius-full, 9999px);
}
.rounded-bl-none {
border-bottom-left-radius: 0;
border-bottom-left-radius: var(--radius-none, 0px);
}
.rounded-bl-sm {
@ -12688,6 +12712,9 @@ test('transition', async () => {
transition-property: opacity;
transition-duration: .1s;
transition-timing-function: ease;
transition-property: var(--transition-property-opacity, opacity);
transition-duration: .1s;
transition-timing-function: ease;
}
.transition-shadow {
@ -14129,6 +14156,14 @@ test('inset-shadow', async () => {
--inset-shadow-sm: inset 0 1px 1px #0000000d;
}
.inset-shadow {
inset: var(--inset-shadow, inset 0 2px 4px #0000000d);
}
.inset-shadow-sm {
inset: var(--inset-shadow-sm, inset 0 1px 1px #0000000d);
}
.inset-shadow {
--tw-inset-shadow: inset 0 2px 4px #0000000d;
--tw-inset-shadow-colored: inset 0 2px 4px var(--tw-inset-shadow-color);
@ -15096,7 +15131,7 @@ describe('custom utilities', () => {
`)
})
test('The later version of a static utility is used', async () => {
test('Multiple static utilities are merged', async () => {
let { build } = await compile(css`
@layer utilities {
@tailwind utilities;
@ -15116,6 +15151,7 @@ describe('custom utilities', () => {
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.really-round {
--custom-prop: hi;
border-radius: 30rem;
}
}"
@ -15159,8 +15195,8 @@ describe('custom utilities', () => {
}
@utility text-sm {
font-size: var(--font-size-sm, 0.875rem);
line-height: var(--font-size-sm--line-height, 1.25rem);
font-size: var(--font-size-sm, 0.8755rem);
line-height: var(--font-size-sm--line-height, 1.255rem);
text-rendering: optimizeLegibility;
}
`)
@ -15171,6 +15207,8 @@ describe('custom utilities', () => {
.text-sm {
font-size: var(--font-size-sm, .875rem);
line-height: var(--font-size-sm--line-height, 1.25rem);
font-size: var(--font-size-sm, .8755rem);
line-height: var(--font-size-sm--line-height, 1.255rem);
text-rendering: optimizeLegibility;
}
}"

View File

@ -27,14 +27,18 @@ type SuggestionDefinition =
hasDefaultValue?: boolean
}
export type UtilityOptions = {
types: string[]
}
export type Utility = {
kind: 'static' | 'functional'
compileFn: CompileFn<any>
options?: UtilityOptions
}
export class Utilities {
private utilities = new DefaultMap<
string,
{
kind: 'static' | 'functional'
compileFn: CompileFn<any>
}[]
>(() => [])
private utilities = new DefaultMap<string, Utility[]>(() => [])
private completions = new Map<string, () => SuggestionGroup[]>()
@ -42,8 +46,8 @@ export class Utilities {
this.utilities.get(name).push({ kind: 'static', compileFn })
}
functional(name: string, compileFn: CompileFn<'functional'>) {
this.utilities.get(name).push({ kind: 'functional', compileFn })
functional(name: string, compileFn: CompileFn<'functional'>, options?: UtilityOptions) {
this.utilities.get(name).push({ kind: 'functional', compileFn, options })
}
has(name: string, kind: 'static' | 'functional') {
@ -2441,9 +2445,6 @@ export function createUtilities(theme: Theme) {
}
}
staticUtility('bg-inherit', [['background-color', 'inherit']])
staticUtility('bg-transparent', [['background-color', 'transparent']])
staticUtility('bg-auto', [['background-size', 'auto']])
staticUtility('bg-cover', [['background-size', 'cover']])
staticUtility('bg-contain', [['background-size', 'contain']])

View File

@ -0,0 +1 @@
module.exports = require('tailwindcss-animate')

6
pnpm-lock.yaml generated
View File

@ -4077,7 +4077,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.0)
eslint-plugin-react: 7.35.0(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0)
@ -4101,7 +4101,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.6
is-core-module: 2.15.0
@ -4123,7 +4123,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5