Skip to content

Commit

Permalink
Support complex addUtilities() configs (#15029)
Browse files Browse the repository at this point in the history
This PR adds support for complex `addUtilities()` configuration objects
that use child combinators and other features.

For example, in v3 it was possible to add a utility that changes the
behavior of all children of the utility class node by doing something
like this:

```ts
addUtilities({
  '.red-children > *': {
    color: 'red',
  },
});
```

This is a pattern that was used by first-party plugins like
`@tailwindcss/aspect-ratio` but that we never made working in v4, since
it requires parsing the selector and properly extracting all utility
candidates.

While working on the codemod that can transform `@layer utilities`
scoped declarations like the above, we found out a pretty neat
heuristics on how to migrate these cases. We're basically finding all
class selectors and replace them with `&`. Then we create a nested CSS
structure like this:

```css
.red-children {
  & > * {
    color: red;
  }
}
```

Due to first party support for nesting, this works as expected in v4.

## Test Plan

We added unit tests to ensure the rewriting works in some edge cases.
Furthermore we added an integration test running the
`@tailwindcss/aspect-ratio` plugin. We've also installed the tarballs in
the Remix example from the
[playgrounds](https://github.com/philipp-spiess/tailwindcss-playgrounds)
and ensure we can use the `@tailwindcss/aspect-ratio` plugin just like
we could in v3:
 
<img width="2560" alt="Screenshot 2024-11-18 at 13 44 52"
src="https://github.com/user-attachments/assets/31889131-fad0-4c37-b574-cfac2b99f786">

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
  • Loading branch information
3 people authored Nov 19, 2024
1 parent 93f9c99 commit ab9e2b7
Show file tree
Hide file tree
Showing 6 changed files with 811 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reintroduce `max-w-screen-*` utilities that read from the `--breakpoint` namespace as deprecated utilities ([#15013](https://github.com/tailwindlabs/tailwindcss/pull/15013))
- Support using CSS variables as arbitrary values without `var(…)` by using parentheses instead of square brackets (e.g. `bg-(--my-color)`) ([#15020](https://github.com/tailwindlabs/tailwindcss/pull/15020))
- Add new `in-*` variant ([#15025](https://github.com/tailwindlabs/tailwindcss/pull/15025))
- Allow `addUtilities()` and `addComponents()` to work with child combinators and other complex selectors ([#15029](https://github.com/tailwindlabs/tailwindcss/pull/15029))
- _Upgrade (experimental)_: Migrate `[&>*]` to the `*` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))
- _Upgrade (experimental)_: Migrate `[&_*]` to the `**` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))

Expand Down
40 changes: 40 additions & 0 deletions integrations/cli/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,46 @@ test(
},
)

test(
'builds the `@tailwindcss/aspect-ratio` plugin utilities',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="aspect-w-16 aspect-h-9">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin '@tailwindcss/aspect-ratio';
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')

await fs.expectFileToContain('dist/out.css', [
//
candidate`aspect-w-16`,
candidate`aspect-h-9`,
])
},
)

test(
'builds the `tailwindcss-animate` plugin utilities',
{
Expand Down
169 changes: 161 additions & 8 deletions packages/tailwindcss/src/compat/plugin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2760,7 +2760,7 @@ describe('addUtilities()', () => {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.text-trim > *': {
':hover > *': {
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
},
Expand Down Expand Up @@ -2842,18 +2842,171 @@ describe('addUtilities()', () => {
},
)

expect(optimizeCss(compiled.build(['form-input', 'lg:form-textarea'])).trim())
.toMatchInlineSnapshot(`
".form-input, .form-input::placeholder {
expect(compiled.build(['form-input', 'lg:form-textarea']).trim()).toMatchInlineSnapshot(`
".form-input {
background-color: red;
&::placeholder {
background-color: red;
}
}
.lg\\:form-textarea {
@media (width >= 1024px) {
.lg\\:form-textarea:hover:focus {
&:hover:focus {
background-color: red;
}
}"
`)
}
}"
`)
})

test('nests complex utility names', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
`,
{
async loadModule(id, base) {
return {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.a .b:hover .c': {
color: 'red',
},
'.d > *': {
color: 'red',
},
'.e .bar:not(.f):has(.g)': {
color: 'red',
},
'.h~.i': {
color: 'red',
},
'.j.j': {
color: 'red',
},
})
},
}
},
},
)

expect(
compiled.build(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']).trim(),
).toMatchInlineSnapshot(
`
"@layer utilities {
.a {
& .b:hover .c {
color: red;
}
}
.b {
.a &:hover .c {
color: red;
}
}
.c {
.a .b:hover & {
color: red;
}
}
.d {
& > * {
color: red;
}
}
.e {
& .bar:not(.f):has(.g) {
color: red;
}
}
.g {
.e .bar:not(.f):has(&) {
color: red;
}
}
.h {
&~.i {
color: red;
}
}
.i {
.h~& {
color: red;
}
}
.j {
&.j {
color: red;
}
.j& {
color: red;
}
}
}"
`,
)
})

test('prefixes nested class names with the configured theme prefix', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
@theme prefix(tw) {
}
`,
{
async loadModule(id, base) {
return {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.a .b:hover .c.d': {
color: 'red',
},
})
},
}
},
},
)

expect(compiled.build(['tw:a', 'tw:b', 'tw:c', 'tw:d']).trim()).toMatchInlineSnapshot(
`
"@layer utilities {
.tw\\:a {
& .tw\\:b:hover .tw\\:c.tw\\:d {
color: red;
}
}
.tw\\:b {
.tw\\:a &:hover .tw\\:c.tw\\:d {
color: red;
}
}
.tw\\:c {
.tw\\:a .tw\\:b:hover &.tw\\:d {
color: red;
}
}
.tw\\:d {
.tw\\:a .tw\\:b:hover .tw\\:c& {
color: red;
}
}
}
:root {
}"
`,
)
})
})

Expand Down
76 changes: 53 additions & 23 deletions packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { substituteAtApply } from '../apply'
import { atRule, decl, rule, type AstNode } from '../ast'
import { atRule, decl, rule, walk, type AstNode } from '../ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate'
import { substituteFunctions } from '../css-functions'
import * as CSS from '../css-parser'
import type { DesignSystem } from '../design-system'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { inferDataType } from '../utils/infer-data-type'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
import { compoundsForSelectors, substituteAtSlot } from '../variants'
import type { ResolvedConfig, UserConfig } from './config/types'
import { createThemeFn } from './plugin-functions'
import * as SelectorParser from './selector-parser'

export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
Expand Down Expand Up @@ -198,40 +200,68 @@ export function buildPluginApi(
)

// Merge entries for the same class
let utils: Record<string, CssInJs[]> = {}
let utils = new DefaultMap<string, AstNode[]>(() => [])

for (let [name, css] of entries) {
let [className, ...parts] = segment(name, ':')

// Modify classes using pseudo-classes or pseudo-elements to use nested rules
if (parts.length > 0) {
let pseudos = parts.map((p) => `:${p.trim()}`).join('')
css = {
[`&${pseudos}`]: css,
}
}

utils[className] ??= []
css = Array.isArray(css) ? css : [css]
utils[className].push(...css)
}

for (let [name, css] of Object.entries(utils)) {
if (name.startsWith('@keyframes ')) {
ast.push(rule(name, objectToAst(css)))
continue
}

if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) {
let selectorAst = SelectorParser.parse(name)
let foundValidUtility = false

SelectorParser.walk(selectorAst, (node) => {
if (
node.kind === 'selector' &&
node.value[0] === '.' &&
IS_VALID_UTILITY_NAME.test(node.value.slice(1))
) {
let value = node.value
node.value = '&'
let selector = SelectorParser.toCss(selectorAst)

let className = value.slice(1)
let contents = selector === '&' ? objectToAst(css) : [rule(selector, objectToAst(css))]
utils.get(className).push(...contents)
foundValidUtility = true

node.value = value
return
}

if (node.kind === 'function' && node.value === ':not') {
return SelectorParser.SelectorWalkAction.Skip
}
})

if (!foundValidUtility) {
throw new Error(
`\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`,
)
}
}

for (let [className, ast] of utils) {
// Prefix all class selector with the configured theme prefix
if (designSystem.theme.prefix) {
walk(ast, (node) => {
if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
SelectorParser.walk(selectorAst, (node) => {
if (node.kind === 'selector' && node.value[0] === '.') {
node.value = `.${designSystem.theme.prefix}\\:${node.value.slice(1)}`
}
})
node.selector = SelectorParser.toCss(selectorAst)
}
})
}

designSystem.utilities.static(name.slice(1), () => {
let ast = objectToAst(css)
substituteAtApply(ast, designSystem)
return ast
designSystem.utilities.static(className, () => {
let clonedAst = structuredClone(ast)
substituteAtApply(clonedAst, designSystem)
return clonedAst
})
}
},
Expand Down
Loading

0 comments on commit ab9e2b7

Please sign in to comment.