Add matchVariant API (#14371)

This PR adds support for the `matchVariant` plugin API. I've copied over
all [V3
tests](f07dbff2a7/tests/match-variants.test.js)
and made sure they still pass.

## Sorted order of stacked arbitrary variants

The only difference in behavior is regarding the sort order of stacked
arbitrary variants: Sorting in this case now works by the latest defined
`matchVariant` taking precedence.

So, if you define a plugin like this:

```ts
matchVariant('testmin', (value) => `@media (min-width: ${value})`, {
  sort(a, z) {
    return parseInt(a.value) - parseInt(z.value)
  },
})

matchVariant('testmax', (value) => `@media (max-width: ${value})`, {
  sort(a, z) {
    return parseInt(z.value) - parseInt(a.value)
  },
})
```

The resulting CSS is first sorted by the `testmax` values descending and
then the `testmin` values ascending, so these candidates:

```txt
testmin-[150px]:testmax-[400px]:order-2
testmin-[100px]:testmax-[350px]:order-3
testmin-[100px]:testmax-[300px]:order-4
testmin-[100px]:testmax-[400px]:order-1
```

Will resolve to the order outlined by the `order-` utility.

## At-rules and placeholders support

Since we added support for at-rules and placeholders in the
`matchVariant` syntax like this:

```ts
matchVariant(
  'potato',
  (flavor) => `@media (potato: ${flavor}) { @supports (font:bold) { &:large-potato } }`,
)
```

We also added support for the same syntax to the `addVariant` API:

```ts
addVariant(
  'potato',
  '@media (max-width: 400px) { @supports (font:bold) { &:large-potato } }',
)
```

The only change necessary in core was to call functional variants for
when the variant value is set to `null`. This allows functional variants
to define the un-parameterized implementation like `potato:underline` as
opposed to `potato[big]:underline`.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Philipp Spiess 2024-09-11 16:45:07 +02:00 committed by GitHub
parent 8b0fff6edd
commit 1e0dfbc6d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1145 additions and 38 deletions

View File

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support CSS `theme()` functions inside other `@custom-media`, `@container`, and `@supports` rules ([#14358])(https://github.com/tailwindlabs/tailwindcss/pull/14358)
- Export `Config` type from `tailwindcss` for JS config files ([#14360])(https://github.com/tailwindlabs/tailwindcss/pull/14360)
- Add support for `matchVariant` plugins using the `@plugin` directive ([#14371](https://github.com/tailwindlabs/tailwindcss/pull/14371))
### Fixed

View File

@ -572,7 +572,15 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
}
case 'functional': {
if (value === null) return null
if (value === null) {
return {
kind: 'functional',
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: null,
compounds: designSystem.variants.compounds(root),
}
}
if (value[0] === '[' && value[value.length - 1] === ']') {
return {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { substituteAtApply } from './apply'
import { decl, rule, type AstNode } from './ast'
import type { Candidate, NamedUtilityValue } from './candidate'
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
import { applyConfigToTheme } from './compat/apply-config-to-theme'
import { createCompatConfig } from './compat/config/create-compat-config'
import { resolveConfig } from './compat/config/resolve-config'
@ -8,12 +8,14 @@ import type { ResolvedConfig, UserConfig } from './compat/config/types'
import { darkModePlugin } from './compat/dark-mode'
import { createThemeFn } from './compat/plugin-functions'
import { substituteFunctions } from './css-functions'
import * as CSS from './css-parser'
import type { DesignSystem } from './design-system'
import type { Theme, ThemeKey } from './theme'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
import { toKeyPath } from './utils/to-key-path'
import { substituteAtSlot } from './variants'
export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
@ -27,7 +29,19 @@ export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions<any>
export type PluginAPI = {
addBase(base: CssInJs): void
addVariant(name: string, variant: string | string[] | CssInJs): void
matchVariant<T = string>(
name: string,
cb: (value: T | string, extra: { modifier: string | null }) => string | string[],
options?: {
values?: Record<string, T>
sort?(
a: { value: T | string; modifier: string | null },
b: { value: T | string; modifier: string | null },
): number
},
): void
addUtilities(
utilities: Record<string, CssInJs | CssInJs[]> | Record<string, CssInJs | CssInJs[]>[],
@ -81,17 +95,10 @@ function buildPluginApi(
},
addVariant(name, variant) {
// Single selector
if (typeof variant === 'string') {
// Single selector or multiple parallel selectors
if (typeof variant === 'string' || Array.isArray(variant)) {
designSystem.variants.static(name, (r) => {
r.nodes = [rule(variant, r.nodes)]
})
}
// Multiple parallel selectors
else if (Array.isArray(variant)) {
designSystem.variants.static(name, (r) => {
r.nodes = variant.map((selector) => rule(selector, r.nodes))
r.nodes = parseVariantValue(variant, r.nodes)
})
}
@ -100,6 +107,71 @@ function buildPluginApi(
designSystem.variants.fromAst(name, objectToAst(variant))
}
},
matchVariant(name, fn, options) {
function resolveVariantValue<T extends Parameters<typeof fn>[0]>(
value: T,
modifier: CandidateModifier | null,
nodes: AstNode[],
): AstNode[] {
let resolved = fn(value, { modifier: modifier?.value ?? null })
return parseVariantValue(resolved, nodes)
}
let defaultOptionKeys = Object.keys(options?.values ?? {})
designSystem.variants.group(
() => {
designSystem.variants.functional(name, (ruleNodes, variant) => {
if (!variant.value || variant.modifier) {
if (options?.values && 'DEFAULT' in options.values) {
ruleNodes.nodes = resolveVariantValue(options.values.DEFAULT, null, ruleNodes.nodes)
return
}
return null
}
if (variant.value.kind === 'arbitrary') {
ruleNodes.nodes = resolveVariantValue(
variant.value.value,
variant.modifier,
ruleNodes.nodes,
)
} else if (variant.value.kind === 'named' && options?.values) {
let defaultValue = options.values[variant.value.value]
if (typeof defaultValue !== 'string') {
return
}
ruleNodes.nodes = resolveVariantValue(defaultValue, null, ruleNodes.nodes)
}
})
},
(a, z) => {
// Since we only define a functional variant in the group, the `kind`
// has to be `functional`.
if (a.kind !== 'functional' || z.kind !== 'functional') {
return 0
}
if (!a.value || !z.value) {
return 0
}
if (options && typeof options.sort === 'function') {
let aValue = options.values?.[a.value.value] ?? a.value.value
let zValue = options.values?.[z.value.value] ?? z.value.value
return options.sort(
{ value: aValue, modifier: a.modifier?.value ?? null },
{ value: zValue, modifier: z.modifier?.value ?? null },
)
}
let aOrder = defaultOptionKeys.indexOf(a.value.value)
let zOrder = defaultOptionKeys.indexOf(z.value.value)
return aOrder - zOrder
},
)
},
addUtilities(utilities) {
utilities = Array.isArray(utilities) ? utilities : [utilities]
@ -350,6 +422,20 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] {
return ast
}
function parseVariantValue(resolved: string | string[], nodes: AstNode[]): AstNode[] {
let resolvedArray = typeof resolved === 'string' ? [resolved] : resolved
return resolvedArray.flatMap((r) => {
if (r.trim().endsWith('}')) {
let updatedCSS = r.replace('}', '{@slot}}')
let ast = CSS.parse(updatedCSS)
substituteAtSlot(ast, nodes)
return ast
} else {
return rule(r, nodes)
}
})
}
type Primitive = string | number | boolean | null
export type CssPluginOptions = Record<string, Primitive | Primitive[]>

View File

@ -1220,7 +1220,7 @@ test('sorting stacked min-* and max-* variants', async () => {
}
@tailwind utilities;
`,
['min-sm:max-xl:flex', 'min-md:max-xl:flex', 'min-xs:max-xl:flex'],
['min-sm:max-lg:flex', 'min-sm:max-xl:flex', 'min-md:max-lg:flex', 'min-xs:max-sm:flex'],
),
).toMatchInlineSnapshot(`
":root {
@ -1232,8 +1232,8 @@ test('sorting stacked min-* and max-* variants', async () => {
}
@media (width >= 280px) {
@media (width < 1280px) {
.min-xs\\:max-xl\\:flex {
@media (width < 640px) {
.min-xs\\:max-sm\\:flex {
display: flex;
}
}
@ -1247,9 +1247,17 @@ test('sorting stacked min-* and max-* variants', async () => {
}
}
@media (width >= 640px) {
@media (width < 1024px) {
.min-sm\\:max-lg\\:flex {
display: flex;
}
}
}
@media (width >= 768px) {
@media (width < 1280px) {
.min-md\\:max-xl\\:flex {
@media (width < 1024px) {
.min-md\\:max-lg\\:flex {
display: flex;
}
}

View File

@ -45,27 +45,7 @@ export class Variants {
fromAst(name: string, ast: AstNode[]) {
this.static(name, (r) => {
let body = structuredClone(ast)
walk(body, (node, { replaceWith }) => {
// Replace `@slot` with rule nodes
if (node.kind === 'rule' && node.selector === '@slot') {
replaceWith(r.nodes)
}
// Wrap `@keyframes` and `@property` in `@at-root`
else if (
node.kind === 'rule' &&
node.selector[0] === '@' &&
(node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property '))
) {
Object.assign(node, {
selector: '@at-root',
nodes: [rule(node.selector, node.nodes)],
})
return WalkAction.Skip
}
})
substituteAtSlot(body, r.nodes)
r.nodes = body
})
}
@ -940,3 +920,25 @@ function quoteAttributeValue(value: string) {
}
return value
}
export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) {
walk(ast, (node, { replaceWith }) => {
// Replace `@slot` with rule nodes
if (node.kind === 'rule' && node.selector === '@slot') {
replaceWith(nodes)
}
// Wrap `@keyframes` and `@property` in `@at-root`
else if (
node.kind === 'rule' &&
node.selector[0] === '@' &&
(node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property '))
) {
Object.assign(node, {
selector: '@at-root',
nodes: [rule(node.selector, node.nodes)],
})
return WalkAction.Skip
}
})
}