diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts
index 1063f1e90fe6..e3b5042bb70a 100644
--- a/packages/tailwindcss/src/plugin-api.test.ts
+++ b/packages/tailwindcss/src/plugin-api.test.ts
@@ -1157,6 +1157,1018 @@ 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('@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}) { &:potato }`)
+ }
+ },
+ },
+ )
+ let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex'])
+
+ expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
+ "@layer utilities {
+ @media (potato: yellow) {
+ .potato-\\[yellow\\]\\:underline:potato {
+ text-decoration-line: underline;
+ }
+ }
+
+ @media (potato: baked) {
+ .potato-\\[baked\\]\\:flex: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.skip('matchVariant can return an array of format strings from the function', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
`,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('test', (selector) =>
+ selector.split(',').map((selector) => `&.${selector} > *`),
+ )
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .test-\[a\,b\,c\]\:underline.a > *,
+ .test-\[a\,b\,c\]\:underline.b > *,
+ .test-\[a\,b\,c\]\:underline.c > * {
+ text-decoration-line: underline;
+ }
+ `)
+ })
+ })
+
+ test.skip('should be possible to sort variants', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('testmin', (value) => `@media (min-width: ${value})`, {
+ sort(a, z) {
+ return parseInt(a.value) - parseInt(z.value)
+ },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 500px) {
+ .testmin-\[500px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (min-width: 700px) {
+ .testmin-\[700px\]\:italic {
+ font-style: italic;
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should be possible to compare arbitrary variants and hardcoded variants', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('testmin', (value) => `@media (min-width: ${value})`, {
+ values: {
+ example: '600px',
+ },
+ sort(a, z) {
+ return parseInt(a.value) - parseInt(z.value)
+ },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 500px) {
+ .testmin-\[500px\]\:italic {
+ font-style: italic;
+ }
+ }
+ @media (min-width: 600px) {
+ .testmin-example\:italic {
+ font-style: italic;
+ }
+ }
+ @media (min-width: 700px) {
+ .testmin-\[700px\]\:italic {
+ font-style: italic;
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should be possible to sort stacked arbitrary variants correctly', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ 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 input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 100px) {
+ @media (max-width: 400px) {
+ .testmin-\[100px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (max-width: 350px) {
+ .testmin-\[100px\]\:testmax-\[350px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (max-width: 300px) {
+ .testmin-\[100px\]\:testmax-\[300px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ @media (min-width: 150px) {
+ @media (max-width: 400px) {
+ .testmin-\[150px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should maintain sort from other variants, if sort functions of arbitrary variants return 0', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ 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 input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 100px) {
+ @media (max-width: 200px) {
+ .testmin-\[100px\]\:testmax-\[200px\]\:hover\:underline:hover,
+ .testmin-\[100px\]\:testmax-\[200px\]\:focus\:underline:focus {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should sort arbitrary variants left to right (1)', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ 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 input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 100px) {
+ @media (max-width: 400px) {
+ .testmin-\[100px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (max-width: 300px) {
+ .testmin-\[100px\]\:testmax-\[300px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ @media (min-width: 200px) {
+ @media (max-width: 400px) {
+ .testmin-\[200px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (max-width: 300px) {
+ .testmin-\[200px\]\:testmax-\[300px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should sort arbitrary variants left to right (2)', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ 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 input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (max-width: 400px) {
+ @media (min-width: 100px) {
+ .testmax-\[400px\]\:testmin-\[100px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (min-width: 200px) {
+ .testmax-\[400px\]\:testmin-\[200px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+
+ @media (max-width: 300px) {
+ @media (min-width: 100px) {
+ .testmax-\[300px\]\:testmin-\[100px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ @media (min-width: 200px) {
+ .testmax-\[300px\]\:testmin-\[200px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should guarantee that we are not passing values from other variants to the wrong function', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ 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 input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ @media (min-width: 100px) {
+ @media (max-width: 400px) {
+ .testmin-\[100px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+
+ @media (max-width: 300px) {
+ .testmin-\[100px\]\:testmax-\[300px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+
+ @media (min-width: 200px) {
+ @media (max-width: 400px) {
+ .testmin-\[200px\]\:testmax-\[400px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+
+ @media (max-width: 300px) {
+ .testmin-\[200px\]\:testmax-\[300px\]\:underline {
+ text-decoration-line: underline;
+ }
+ }
+ }
+ `)
+ })
+ })
+
+ test.skip('should default to the DEFAULT value for variants', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('foo', (value) => `.foo${value} &`, {
+ values: {
+ DEFAULT: '.bar',
+ },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo.bar .foo\:underline {
+ text-decoration-line: underline;
+ }
+ `)
+ })
+ })
+
+ test.skip('should not generate anything if the matchVariant does not have a DEFAULT value configured', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('foo', (value) => `.foo${value} &`)
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css``)
+ })
+ })
+
+ test.skip('should be possible to use `null` as a DEFAULT value', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('foo', (value) => `.foo${value === null ? '-good' : '-bad'} &`, {
+ values: { DEFAULT: null },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo-good .foo\:underline {
+ text-decoration-line: underline;
+ }
+ `)
+ })
+ })
+
+ test.skip('should be possible to use `undefined` as a DEFAULT value', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('foo', (value) => `.foo${value === undefined ? '-good' : '-bad'} &`, {
+ values: { DEFAULT: undefined },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo-good .foo\:underline {
+ text-decoration-line: underline;
+ }
+ `)
+ })
+ })
+
+ test.skip('should be possible to use `undefined` as a DEFAULT value', () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ plugins: [
+ ({ matchVariant }) => {
+ matchVariant('foo', (value) => `.foo${value === undefined ? '-good' : '-bad'} &`, {
+ values: { DEFAULT: undefined },
+ })
+ },
+ ],
+ }
+
+ let input = css`
+ @tailwind utilities;
+ `
+
+ return run(input, config).then((result) => {
+ expect(result.css).toMatchFormattedCss(css`
+ .foo-good .foo\:underline {
+ text-decoration-line: underline;
+ }
+ `)
+ })
+ })
+
+ test.skip('should not break things', () => {
+ let config = {}
+
+ let context = createContext(resolveConfig(config))
+ let [[, fn]] = context.variantMap.get('group')
+
+ let format
+
+ expect(
+ fn({
+ format(input) {
+ format = input
+ },
+ }),
+ ).toBe(undefined)
+
+ expect(format).toBe(':merge(.group) &')
+ })
+})
+
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 54027dfa3a13..6816ae44546e 100644
--- a/packages/tailwindcss/src/plugin-api.ts
+++ b/packages/tailwindcss/src/plugin-api.ts
@@ -1,5 +1,5 @@
import { substituteAtApply } from './apply'
-import { decl, rule, type AstNode } from './ast'
+import { decl, rule, walk, type AstNode } from './ast'
import type { Candidate, NamedUtilityValue } from './candidate'
import { applyConfigToTheme } from './compat/apply-config-to-theme'
import { createCompatConfig } from './compat/config/create-compat-config'
@@ -7,6 +7,7 @@ import { resolveConfig, type ConfigFile } from './compat/config/resolve-config'
import type { ResolvedConfig, UserConfig } from './compat/config/types'
import { darkModePlugin } from './compat/dark-mode'
import { createThemeFn } from './compat/plugin-functions'
+import * as CSS from './css-parser'
import type { DesignSystem } from './design-system'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
@@ -25,7 +26,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[],
@@ -96,6 +109,66 @@ function buildPluginApi(
designSystem.variants.fromAst(name, objectToAst(variant))
}
},
+ matchVariant(name, fn, options) {
+ function resolveVariantValue(
+ value: string,
+ modifier: string | null,
+ nodes: AstNode[],
+ ): AstNode[] {
+ let resolved = fn(value, { modifier })
+ return (typeof resolved === 'string' ? [resolved] : resolved).flatMap((r) => {
+ if (r.includes('{')) {
+ let ast = CSS.parse(r)
+ walk(ast, (node, { replaceWith }) => {
+ if (node.kind === 'declaration' && node.property === '&' && node.value === name) {
+ replaceWith(rule(`&:${name}`, nodes))
+ return
+ }
+ })
+ return ast
+ } else {
+ return rule(r, nodes)
+ }
+ })
+ }
+
+ let defaultOptionKeys = Object.keys(options?.values ?? {})
+ designSystem.variants.group(
+ () => {
+ designSystem.variants.functional(name, (ruleNodes, variant) => {
+ if (!variant.value || variant.modifier) 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) => {
+ if (a.kind !== 'functional' || z.kind !== 'functional') {
+ return 0
+ }
+ if (!a.value || !z.value) {
+ return 0
+ }
+
+ let aOrder = defaultOptionKeys.indexOf(a.value.value) ?? 0
+ let zOrder = defaultOptionKeys.indexOf(z.value.value) ?? 0
+
+ return aOrder - zOrder
+ },
+ )
+ },
addUtilities(utilities) {
utilities = Array.isArray(utilities) ? utilities : [utilities]