mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
8b0fff6edd
commit
1e0dfbc6d0
@ -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
|
||||
|
||||
|
||||
@ -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
@ -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[]>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user