diff --git a/CHANGELOG.md b/CHANGELOG.md index 490f1ff4266b..30716f0ab012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 80bf1edbcf71..2f46dbbbe48e 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -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 { diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 1063f1e90fe6..ca16ab97780d 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1157,6 +1157,1008 @@ describe('theme', async () => { }) }) +describe('addVariant', () => { + test('addVariant with string selector', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', '&:hover, &:focus') + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with array of selectors', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', ['&:hover', '&:focus']) + } + }, + }, + ) + + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax and @slot', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '&:hover': '@slot', + '&:focus': '@slot', + }) + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax, media, nesting and multiple @slot', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '@media (hover: hover)': { + '&:hover': '@slot', + }, + '&:focus': '@slot', + }) + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:flex:is(:where(.group):hover *) { + display: flex; + } + } + + .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + @media (hover: hover) { + .hocus\\:underline:hover { + text-decoration-line: underline; + } + } + + .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with at-rules and placeholder', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant( + 'potato', + '@media (max-width: 400px) { @supports (font:bold) { &:large-potato } }', + ) + } + }, + }, + ) + let compiled = build(['potato:underline', 'potato:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width <= 400px) { + @supports (font: bold) { + .potato\\:flex:large-potato { + display: flex; + } + } + } + + @media (width <= 400px) { + @supports (font: bold) { + .potato\\:underline:large-potato { + text-decoration-line: underline; + } + } + } + }" + `) + }) + + test('@slot is preserved when used as a custom property value', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '&': { + '--custom-property': '@slot', + '&:hover': '@slot', + '&:focus': '@slot', + }, + }) + } + }, + }, + ) + let compiled = build(['hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .hocus\\:underline { + --custom-property: @slot; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) +}) + +describe('matchVariant', () => { + test('partial arbitrary variants', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('potato', (flavor) => `.potato-${flavor} &`) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .potato-yellow .potato-\\[yellow\\]\\:underline { + text-decoration-line: underline; + } + + .potato-baked .potato-\\[baked\\]\\:flex { + display: flex; + } + }" + `) + }) + + test('partial arbitrary variants with at-rules', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('potato', (flavor) => `@media (potato: ${flavor})`) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (potato: yellow) { + .potato-\\[yellow\\]\\:underline { + text-decoration-line: underline; + } + } + + @media (potato: baked) { + .potato-\\[baked\\]\\:flex { + display: flex; + } + } + }" + `) + }) + + test('partial arbitrary variants with at-rules and placeholder', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant( + 'potato', + (flavor) => `@media (potato: ${flavor}) { @supports (font:bold) { &:large-potato } }`, + ) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (potato: yellow) { + @supports (font: bold) { + .potato-\\[yellow\\]\\:underline:large-potato { + text-decoration-line: underline; + } + } + } + + @media (potato: baked) { + @supports (font: bold) { + .potato-\\[baked\\]\\:flex:large-potato { + display: flex; + } + } + } + }" + `) + }) + + test('partial arbitrary variants with default values', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('tooltip', (side) => `&${side}`, { + values: { + bottom: '[data-location="bottom"]', + top: '[data-location="top"]', + }, + }) + } + }, + }, + ) + let compiled = build(['tooltip-bottom:underline', 'tooltip-top:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .tooltip-bottom\\:underline[data-location="bottom"] { + text-decoration-line: underline; + } + + .tooltip-top\\:flex[data-location="top"] { + display: flex; + } + }" + `) + }) + + test('matched variant values maintain the sort order they are registered in', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('alphabet', (side) => `&${side}`, { + values: { + a: '[data-value="a"]', + b: '[data-value="b"]', + c: '[data-value="c"]', + d: '[data-value="d"]', + }, + }) + } + }, + }, + ) + let compiled = build([ + 'alphabet-c:underline', + 'alphabet-a:underline', + 'alphabet-d:underline', + 'alphabet-b:underline', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .alphabet-a\\:underline[data-value="a"] { + text-decoration-line: underline; + } + + .alphabet-b\\:underline[data-value="b"] { + text-decoration-line: underline; + } + + .alphabet-c\\:underline[data-value="c"] { + text-decoration-line: underline; + } + + .alphabet-d\\:underline[data-value="d"] { + text-decoration-line: underline; + } + }" + `) + }) + + test('matchVariant can return an array of format strings from the function', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('test', (selector) => + selector.split(',').map((selector) => `&.${selector} > *`), + ) + } + }, + }, + ) + let compiled = build(['test-[a,b,c]:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .test-\\[a\\,b\\,c\\]\\:underline.a > *, .test-\\[a\\,b\\,c\\]\\:underline.b > *, .test-\\[a\\,b\\,c\\]\\:underline.c > * { + text-decoration-line: underline; + } + }" + `) + }) + + test('should be possible to sort variants', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmin-[600px]:flex', + 'testmin-[500px]:underline', + 'testmin-[700px]:italic', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 500px) { + .testmin-\\[500px\\]\\:underline { + text-decoration-line: underline; + } + } + + @media (width >= 600px) { + .testmin-\\[600px\\]\\:flex { + display: flex; + } + } + + @media (width >= 700px) { + .testmin-\\[700px\\]\\:italic { + font-style: italic; + } + } + }" + `) + }) + + test('should be possible to compare arbitrary variants and hardcoded variants', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + values: { + example: '600px', + }, + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmin-[700px]:italic', + 'testmin-example:italic', + 'testmin-[500px]:italic', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 500px) { + .testmin-\\[500px\\]\\:italic { + font-style: italic; + } + } + + @media (width >= 600px) { + .testmin-example\\:italic { + font-style: italic; + } + } + + @media (width >= 700px) { + .testmin-\\[700px\\]\\:italic { + font-style: italic; + } + } + }" + `) + }) + + test('should be possible to sort stacked arbitrary variants correctly', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + 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) + }, + }) + } + }, + }, + ) + + let compiled = build([ + 'testmin-[150px]:testmax-[400px]:order-2', + 'testmin-[100px]:testmax-[350px]:order-3', + 'testmin-[100px]:testmax-[300px]:order-4', + 'testmin-[100px]:testmax-[400px]:order-1', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 100px) { + @media (width <= 400px) { + .testmin-\\[100px\\]\\:testmax-\\[400px\\]\\:order-1 { + order: 1; + } + } + } + + @media (width >= 150px) { + @media (width <= 400px) { + .testmin-\\[150px\\]\\:testmax-\\[400px\\]\\:order-2 { + order: 2; + } + } + } + + @media (width >= 100px) { + @media (width <= 350px) { + .testmin-\\[100px\\]\\:testmax-\\[350px\\]\\:order-3 { + order: 3; + } + } + } + + @media (width >= 100px) { + @media (width <= 300px) { + .testmin-\\[100px\\]\\:testmax-\\[300px\\]\\:order-4 { + order: 4; + } + } + } + }" + `) + }) + + test('should maintain sort from other variants, if sort functions of arbitrary variants return 0', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + 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) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmin-[100px]:testmax-[200px]:focus:underline', + 'testmin-[100px]:testmax-[200px]:hover:underline', + ]) + + // Expect :focus to come after :hover + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 100px) { + @media (width <= 200px) { + .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:hover\\:underline:hover { + text-decoration-line: underline; + } + } + } + + @media (width >= 100px) { + @media (width <= 200px) { + .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:focus\\:underline:focus { + text-decoration-line: underline; + } + } + } + }" + `) + }) + + test('should sort arbitrary variants left to right (1)', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + 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) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmin-[200px]:testmax-[400px]:order-2', + 'testmin-[200px]:testmax-[300px]:order-4', + 'testmin-[100px]:testmax-[400px]:order-1', + 'testmin-[100px]:testmax-[300px]:order-3', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 100px) { + @media (width <= 400px) { + .testmin-\\[100px\\]\\:testmax-\\[400px\\]\\:order-1 { + order: 1; + } + } + } + + @media (width >= 200px) { + @media (width <= 400px) { + .testmin-\\[200px\\]\\:testmax-\\[400px\\]\\:order-2 { + order: 2; + } + } + } + + @media (width >= 100px) { + @media (width <= 300px) { + .testmin-\\[100px\\]\\:testmax-\\[300px\\]\\:order-3 { + order: 3; + } + } + } + + @media (width >= 200px) { + @media (width <= 300px) { + .testmin-\\[200px\\]\\:testmax-\\[300px\\]\\:order-4 { + order: 4; + } + } + } + }" + `) + }) + + test('should sort arbitrary variants left to right (2)', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + 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) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmax-[400px]:testmin-[200px]:underline', + 'testmax-[300px]:testmin-[200px]:underline', + 'testmax-[400px]:testmin-[100px]:underline', + 'testmax-[300px]:testmin-[100px]:underline', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width <= 400px) { + @media (width >= 100px) { + .testmax-\\[400px\\]\\:testmin-\\[100px\\]\\:underline { + text-decoration-line: underline; + } + } + } + + @media (width <= 400px) { + @media (width >= 200px) { + .testmax-\\[400px\\]\\:testmin-\\[200px\\]\\:underline { + text-decoration-line: underline; + } + } + } + + @media (width <= 300px) { + @media (width >= 100px) { + .testmax-\\[300px\\]\\:testmin-\\[100px\\]\\:underline { + text-decoration-line: underline; + } + } + } + + @media (width <= 300px) { + @media (width >= 200px) { + .testmax-\\[300px\\]\\:testmin-\\[200px\\]\\:underline { + text-decoration-line: underline; + } + } + } + }" + `) + }) + + test('should guarantee that we are not passing values from other variants to the wrong function', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + let lookup = ['100px', '200px'] + if (lookup.indexOf(a.value) === -1 || lookup.indexOf(z.value) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(a.value) - lookup.indexOf(z.value) + }, + }) + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + let lookup = ['300px', '400px'] + if (lookup.indexOf(a.value) === -1 || lookup.indexOf(z.value) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(z.value) - lookup.indexOf(a.value) + }, + }) + } + }, + }, + ) + let compiled = build([ + 'testmin-[200px]:testmax-[400px]:order-2', + 'testmin-[200px]:testmax-[300px]:order-4', + 'testmin-[100px]:testmax-[400px]:order-1', + 'testmin-[100px]:testmax-[300px]:order-3', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (width >= 100px) { + @media (width <= 400px) { + .testmin-\\[100px\\]\\:testmax-\\[400px\\]\\:order-1 { + order: 1; + } + } + } + + @media (width >= 200px) { + @media (width <= 400px) { + .testmin-\\[200px\\]\\:testmax-\\[400px\\]\\:order-2 { + order: 2; + } + } + } + + @media (width >= 100px) { + @media (width <= 300px) { + .testmin-\\[100px\\]\\:testmax-\\[300px\\]\\:order-3 { + order: 3; + } + } + } + + @media (width >= 200px) { + @media (width <= 300px) { + .testmin-\\[200px\\]\\:testmax-\\[300px\\]\\:order-4 { + order: 4; + } + } + } + }" + `) + }) + + test('should default to the DEFAULT value for variants', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('foo', (value) => `.foo${value} &`, { + values: { + DEFAULT: '.bar', + }, + }) + } + }, + }, + ) + let compiled = build(['foo:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .foo.bar .foo\\:underline { + text-decoration-line: underline; + } + }" + `) + }) + + test('should not generate anything if the matchVariant does not have a DEFAULT value configured', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('foo', (value) => `.foo${value} &`) + } + }, + }, + ) + let compiled = build(['foo:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`"@layer utilities;"`) + }) + + test('should be possible to use `null` as a DEFAULT value', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('foo', (value) => `.foo${value === null ? '-good' : '-bad'} &`, { + values: { DEFAULT: null }, + }) + } + }, + }, + ) + let compiled = build(['foo:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .foo-good .foo\\:underline { + text-decoration-line: underline; + } + }" + `) + }) + + test('should be possible to use `undefined` as a DEFAULT value', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('foo', (value) => `.foo${value === undefined ? '-good' : '-bad'} &`, { + values: { DEFAULT: undefined }, + }) + } + }, + }, + ) + let compiled = build(['foo:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .foo-good .foo\\:underline { + text-decoration-line: underline; + } + }" + `) + }) +}) + describe('addUtilities()', () => { test('custom static utility', async () => { let compiled = await compile( diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 20c9219cfeed..16f3a31c24bf 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -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 export type PluginAPI = { addBase(base: CssInJs): void + addVariant(name: string, variant: string | string[] | CssInJs): void + matchVariant( + name: string, + cb: (value: T | string, extra: { modifier: string | null }) => string | string[], + options?: { + values?: Record + sort?( + a: { value: T | string; modifier: string | null }, + b: { value: T | string; modifier: string | null }, + ): number + }, + ): void addUtilities( utilities: Record | Record[], @@ -81,17 +95,10 @@ function buildPluginApi( }, addVariant(name, variant) { - // Single selector - if (typeof variant === 'string') { - designSystem.variants.static(name, (r) => { - r.nodes = [rule(variant, r.nodes)] - }) - } - - // Multiple parallel selectors - else if (Array.isArray(variant)) { + // Single selector or multiple parallel selectors + if (typeof variant === 'string' || 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[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 diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index cfc3d12a534c..5b0b1855ed32 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -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; } } diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 28009817a783..7811043adc66 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -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 + } + }) +}