mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Add support for custom variants via CSS (#13992)
* implement `@variant` in CSS * implement `addVariant(name, objectTree)` * update changelog * ensure that `@variant` can only be used top-level * simplify Plugin API type * Use type instead of interface (for now) * Use more realistic variant for test * Allow custom properties to use `@slot` as content * Use "cannot" instead of "can not" * Remove `@variant` right away * Throw when `@variant` is missing a selector or body * Use "CSS-in-JS" terminology instead of "CSS Tree" * Rename tests * Mark some tests that seem wrong * Tweak comment, remove unnecessary return * Ensure group is usable with custom selector lists * Only apply extra `:is(…)` when there are multiple selectors * Tweak comment * Throw when @variant has both selector and body * Rework tests to use more realistic examples * Compound variants on an isolated copy This prevents traversals from leaking across variants * Handle selector lists for peer variants * Ignore at rules when compounding group and peer variants * Re-enable skipped tests * Update changelog --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
parent
54474086c8
commit
5ebd5896d7
@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982))
|
||||
- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992))
|
||||
|
||||
## [4.0.0-alpha.17] - 2024-07-04
|
||||
|
||||
|
||||
@ -42,6 +42,26 @@ export function comment(value: string): Comment {
|
||||
}
|
||||
}
|
||||
|
||||
export type CssInJs = { [key: string]: string | CssInJs }
|
||||
|
||||
export function objectToAst(obj: CssInJs): AstNode[] {
|
||||
let ast: AstNode[] = []
|
||||
|
||||
for (let [name, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
if (!name.startsWith('--') && value === '@slot') {
|
||||
ast.push(rule(name, [rule('@slot', [])]))
|
||||
} else {
|
||||
ast.push(decl(name, value))
|
||||
}
|
||||
} else {
|
||||
ast.push(rule(name, objectToAst(value)))
|
||||
}
|
||||
}
|
||||
|
||||
return ast
|
||||
}
|
||||
|
||||
export enum WalkAction {
|
||||
/** Continue walking, which is the default */
|
||||
Continue,
|
||||
@ -58,14 +78,17 @@ export function walk(
|
||||
visit: (
|
||||
node: AstNode,
|
||||
utils: {
|
||||
parent: AstNode | null
|
||||
replaceWith(newNode: AstNode | AstNode[]): void
|
||||
},
|
||||
) => void | WalkAction,
|
||||
parent: AstNode | null = null,
|
||||
) {
|
||||
for (let i = 0; i < ast.length; i++) {
|
||||
let node = ast[i]
|
||||
let status =
|
||||
visit(node, {
|
||||
parent,
|
||||
replaceWith(newNode) {
|
||||
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
|
||||
// We want to visit the newly replaced node(s), which start at the
|
||||
@ -82,7 +105,7 @@ export function walk(
|
||||
if (status === WalkAction.Skip) continue
|
||||
|
||||
if (node.kind === 'rule') {
|
||||
walk(node.nodes, visit)
|
||||
walk(node.nodes, visit, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { rule, type AstNode, type Rule } from './ast'
|
||||
import { WalkAction, rule, walk, type AstNode, type Rule } from './ast'
|
||||
import { type Candidate, type Variant } from './candidate'
|
||||
import { type DesignSystem } from './design-system'
|
||||
import GLOBAL_PROPERTY_ORDER from './property-order'
|
||||
@ -170,10 +170,25 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
|
||||
let { applyFn } = variants.get(variant.root)!
|
||||
|
||||
if (variant.kind === 'compound') {
|
||||
let result = applyVariant(node, variant.variant, variants)
|
||||
// Some variants traverse the AST to mutate the nodes. E.g.: `group-*` wants
|
||||
// to prefix every selector of the variant it's compounding with `.group`.
|
||||
//
|
||||
// E.g.:
|
||||
// ```
|
||||
// group-hover:[&_p]:flex
|
||||
// ```
|
||||
//
|
||||
// Should only prefix the `group-hover` part with `.group`, and not the `&_p` part.
|
||||
//
|
||||
// To solve this, we provide an isolated placeholder node to the variant.
|
||||
// The variant can now apply its logic to the isolated node without
|
||||
// affecting the original node.
|
||||
let isolatedNode = rule('@slot', [])
|
||||
|
||||
let result = applyVariant(isolatedNode, variant.variant, variants)
|
||||
if (result === null) return null
|
||||
|
||||
for (let child of node.nodes) {
|
||||
for (let child of isolatedNode.nodes) {
|
||||
// Only some variants wrap children in rules. For example, the `force`
|
||||
// variant is a noop on the AST. And the `has` variant modifies the
|
||||
// selector rather than the children.
|
||||
@ -186,6 +201,17 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
|
||||
let result = applyFn(child as Rule, variant)
|
||||
if (result === null) return null
|
||||
}
|
||||
|
||||
// Replace the placeholder node with the actual node
|
||||
{
|
||||
walk(isolatedNode.nodes, (child) => {
|
||||
if (child.kind === 'rule' && child.nodes.length <= 0) {
|
||||
child.nodes = node.nodes
|
||||
return WalkAction.Skip
|
||||
}
|
||||
})
|
||||
node.nodes = isolatedNode.nodes
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { rule, toCss } from './ast'
|
||||
import { toCss } from './ast'
|
||||
import { parseCandidate, parseVariant } from './candidate'
|
||||
import { compileAstNodes, compileCandidates } from './compile'
|
||||
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
|
||||
@ -8,10 +8,6 @@ import { Utilities, createUtilities } from './utilities'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { Variants, createVariants } from './variants'
|
||||
|
||||
export type Plugin = (api: {
|
||||
addVariant: (name: string, selector: string | string[]) => void
|
||||
}) => void
|
||||
|
||||
export type DesignSystem = {
|
||||
theme: Theme
|
||||
utilities: Utilities
|
||||
@ -29,7 +25,7 @@ export type DesignSystem = {
|
||||
getUsedVariants(): ReturnType<typeof parseVariant>[]
|
||||
}
|
||||
|
||||
export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignSystem {
|
||||
export function buildDesignSystem(theme: Theme): DesignSystem {
|
||||
let utilities = createUtilities(theme)
|
||||
let variants = createVariants(theme)
|
||||
|
||||
@ -81,15 +77,5 @@ export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignS
|
||||
},
|
||||
}
|
||||
|
||||
for (let plugin of plugins) {
|
||||
plugin({
|
||||
addVariant: (name: string, selectors: string | string[]) => {
|
||||
variants.static(name, (r) => {
|
||||
r.nodes = ([] as string[]).concat(selectors).map((selector) => rule(selector, r.nodes))
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return designSystem
|
||||
}
|
||||
|
||||
@ -1141,10 +1141,14 @@ describe('plugins', () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
).build(['hocus:underline'])
|
||||
).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;
|
||||
}
|
||||
@ -1181,4 +1185,556 @@ describe('plugins', () => {
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('addVariant with object syntax and @slot', () => {
|
||||
let compiled = compile(
|
||||
css`
|
||||
@plugin "my-plugin";
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`,
|
||||
{
|
||||
loadPlugin: () => {
|
||||
return ({ addVariant }) => {
|
||||
addVariant('hocus', {
|
||||
'&:hover': '@slot',
|
||||
'&:focus': '@slot',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
).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', () => {
|
||||
let compiled = compile(
|
||||
css`
|
||||
@plugin "my-plugin";
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`,
|
||||
{
|
||||
loadPlugin: () => {
|
||||
return ({ addVariant }) => {
|
||||
addVariant('hocus', {
|
||||
'@media (hover: hover)': {
|
||||
'&:hover': '@slot',
|
||||
},
|
||||
'&:focus': '@slot',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
).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', () => {
|
||||
let compiled = compile(
|
||||
css`
|
||||
@plugin "my-plugin";
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`,
|
||||
{
|
||||
loadPlugin: () => {
|
||||
return ({ addVariant }) => {
|
||||
addVariant('hocus', {
|
||||
'&': {
|
||||
'--custom-property': '@slot',
|
||||
'&:hover': '@slot',
|
||||
'&:focus': '@slot',
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
).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('@variant', () => {
|
||||
test('@variant must be top-level and cannot be nested', () => {
|
||||
expect(() =>
|
||||
compileCss(css`
|
||||
.foo {
|
||||
@variant hocus (&:hover, &:focus);
|
||||
}
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`)
|
||||
})
|
||||
|
||||
test('@variant with no body must include a selector', () => {
|
||||
expect(() =>
|
||||
compileCss(css`
|
||||
@variant hocus;
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]')
|
||||
})
|
||||
|
||||
test('@variant with selector must include a body', () => {
|
||||
expect(() =>
|
||||
compileCss(css`
|
||||
@variant hocus {
|
||||
}
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]')
|
||||
})
|
||||
|
||||
test('@variant cannot have both a selector and a body', () => {
|
||||
expect(() =>
|
||||
compileCss(css`
|
||||
@variant hocus (&:hover, &:focus) {
|
||||
&:is(.potato) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: \`@variant hocus\` cannot have both a selector and a body.]`,
|
||||
)
|
||||
})
|
||||
|
||||
describe('body-less syntax', () => {
|
||||
test('selector variant', () => {
|
||||
let compiled = compile(css`
|
||||
@variant hocus (&:hover, &:focus);
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).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('at-rule variant', () => {
|
||||
let compiled = compile(css`
|
||||
@variant any-hover (@media (any-hover: hover));
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['any-hover:hover:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (any-hover: hover) {
|
||||
.any-hover\\:hover\\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('body with @slot syntax', () => {
|
||||
test('selector with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant selected {
|
||||
&[data-selected] {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['selected:underline', 'group-selected:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
.group-selected\\:underline:is(:where(.group)[data-selected] *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.selected\\:underline[data-selected] {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('grouped selectors with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant hocus {
|
||||
&:hover,
|
||||
&:focus {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['hocus:underline', 'group-hocus:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
.group-hocus\\:underline:is(:is(:where(.group):hover, :where(.group):focus) *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hocus\\:underline:hover, .hocus\\:underline:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('multiple selectors with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant hocus {
|
||||
&:hover {
|
||||
@slot;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['hocus:underline', 'group-hocus:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
.group-hocus\\:underline:is(:where(.group):hover *), .group-hocus\\:underline:is(:where(.group):focus *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hocus\\:underline:hover, .hocus\\:underline:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('nested selector with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant custom-before {
|
||||
& {
|
||||
--has-before: 1;
|
||||
&::before {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['custom-before:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
.custom-before\\:underline {
|
||||
--has-before: 1;
|
||||
}
|
||||
|
||||
.custom-before\\:underline:before {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('grouped nested selectors with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant custom-before {
|
||||
& {
|
||||
--has-before: 1;
|
||||
&::before {
|
||||
&:hover,
|
||||
&:focus {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['custom-before:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
.custom-before\\:underline {
|
||||
--has-before: 1;
|
||||
}
|
||||
|
||||
.custom-before\\:underline:before:hover, .custom-before\\:underline:before:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('nested multiple selectors with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant hocus {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['hocus:underline', 'group-hocus:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (hover: hover) {
|
||||
.group-hocus\\:underline:is(:where(.group):hover *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.group-hocus\\:underline:is(:where(.group):focus *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hocus\\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.hocus\\:underline:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('selector nested under at-rule with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant hocus {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['hocus:underline', 'group-hocus:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (hover: hover) {
|
||||
.group-hocus\\:underline:is(:where(.group):hover *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hocus\\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('at-rule with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant any-hover {
|
||||
@media (any-hover: hover) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['any-hover:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (any-hover: hover) {
|
||||
.any-hover\\:underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('multiple at-rules with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant desktop {
|
||||
@media (any-hover: hover) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['desktop:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (any-hover: hover) {
|
||||
.desktop\\:underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.desktop\\:underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('nested at-rules with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant custom-variant {
|
||||
@media (orientation: landscape) {
|
||||
@media screen {
|
||||
@slot;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['custom-variant:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (orientation: landscape) {
|
||||
@media screen {
|
||||
.custom-variant\\:underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.custom-variant\\:underline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('at-rule and selector with @slot', () => {
|
||||
let compiled = compile(css`
|
||||
@variant custom-dark {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@slot;
|
||||
}
|
||||
&:is(.dark *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`).build(['custom-dark:underline'])
|
||||
|
||||
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.custom-dark\\:underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-dark\\:underline:is(.dark *) {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,26 @@
|
||||
import { version } from '../package.json'
|
||||
import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast'
|
||||
import {
|
||||
WalkAction,
|
||||
comment,
|
||||
decl,
|
||||
objectToAst,
|
||||
rule,
|
||||
toCss,
|
||||
walk,
|
||||
type AstNode,
|
||||
type CssInJs,
|
||||
type Rule,
|
||||
} from './ast'
|
||||
import { compileCandidates } from './compile'
|
||||
import * as CSS from './css-parser'
|
||||
import { buildDesignSystem, type Plugin } from './design-system'
|
||||
import { buildDesignSystem, type DesignSystem } from './design-system'
|
||||
import { Theme } from './theme'
|
||||
import { segment } from './utils/segment'
|
||||
|
||||
type PluginAPI = {
|
||||
addVariant(name: string, variant: string | string[] | CssInJs): void
|
||||
}
|
||||
type Plugin = (api: PluginAPI) => void
|
||||
|
||||
type CompileOptions = {
|
||||
loadPlugin?: (path: string) => Plugin
|
||||
@ -34,10 +51,11 @@ export function compile(
|
||||
// Find all `@theme` declarations
|
||||
let theme = new Theme()
|
||||
let plugins: Plugin[] = []
|
||||
let customVariants: ((designSystem: DesignSystem) => void)[] = []
|
||||
let firstThemeRule: Rule | null = null
|
||||
let keyframesRules: Rule[] = []
|
||||
|
||||
walk(ast, (node, { replaceWith }) => {
|
||||
walk(ast, (node, { parent, replaceWith }) => {
|
||||
if (node.kind !== 'rule') return
|
||||
|
||||
// Collect paths from `@plugin` at-rules
|
||||
@ -47,6 +65,62 @@ export function compile(
|
||||
return
|
||||
}
|
||||
|
||||
// Register custom variants from `@variant` at-rules
|
||||
if (node.selector.startsWith('@variant ')) {
|
||||
if (parent !== null) {
|
||||
throw new Error('`@variant` cannot be nested.')
|
||||
}
|
||||
|
||||
// Remove `@variant` at-rule so it's not included in the compiled CSS
|
||||
replaceWith([])
|
||||
|
||||
let [name, selector] = segment(node.selector.slice(9), ' ')
|
||||
|
||||
if (node.nodes.length > 0 && selector) {
|
||||
throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`)
|
||||
}
|
||||
|
||||
// Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);`
|
||||
if (node.nodes.length === 0) {
|
||||
if (!selector) {
|
||||
throw new Error(`\`@variant ${name}\` has no selector or body.`)
|
||||
}
|
||||
|
||||
let selectors = segment(selector.slice(1, -1), ',')
|
||||
|
||||
customVariants.push((designSystem) => {
|
||||
designSystem.variants.static(name, (r) => {
|
||||
r.nodes = selectors.map((selector) => rule(selector, r.nodes))
|
||||
})
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Variants without a selector, but with a body:
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// @variant hocus {
|
||||
// &:hover {
|
||||
// @slot;
|
||||
// }
|
||||
//
|
||||
// &:focus {
|
||||
// @slot;
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
else {
|
||||
customVariants.push((designSystem) => {
|
||||
designSystem.variants.fromAst(name, node.nodes)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Drop instances of `@media reference`
|
||||
//
|
||||
// We support `@import "tailwindcss/theme" reference` as a way to import an external theme file
|
||||
@ -144,7 +218,38 @@ export function compile(
|
||||
firstThemeRule.nodes = nodes
|
||||
}
|
||||
|
||||
let designSystem = buildDesignSystem(theme, plugins)
|
||||
let designSystem = buildDesignSystem(theme)
|
||||
|
||||
for (let customVariant of customVariants) {
|
||||
customVariant(designSystem)
|
||||
}
|
||||
|
||||
let api: PluginAPI = {
|
||||
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)) {
|
||||
designSystem.variants.static(name, (r) => {
|
||||
r.nodes = variant.map((selector) => rule(selector, r.nodes))
|
||||
})
|
||||
}
|
||||
|
||||
// CSS-in-JS object
|
||||
else if (typeof variant === 'object') {
|
||||
designSystem.variants.fromAst(name, objectToAst(variant))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for (let plugin of plugins) {
|
||||
plugin(api)
|
||||
}
|
||||
|
||||
let tailwindUtilitiesNode: Rule | null = null
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { decl, rule, type Rule } from './ast'
|
||||
import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast'
|
||||
import { type Variant } from './candidate'
|
||||
import type { Theme } from './theme'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { segment } from './utils/segment'
|
||||
|
||||
type VariantFn<T extends Variant['kind']> = (
|
||||
rule: Rule,
|
||||
@ -41,6 +42,34 @@ export class Variants {
|
||||
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
r.nodes = body
|
||||
})
|
||||
}
|
||||
|
||||
functional(
|
||||
name: string,
|
||||
applyFn: VariantFn<'functional'>,
|
||||
@ -190,15 +219,29 @@ export function createVariants(theme: Theme): Variants {
|
||||
? `:where(.group\\/${variant.modifier.value})`
|
||||
: ':where(.group)'
|
||||
|
||||
// For most variants we rely entirely on CSS nesting to build-up the final
|
||||
// selector, but there is no way to use CSS nesting to make `&` refer to
|
||||
// just the `.group` class the way we'd need to for these variants, so we
|
||||
// need to replace it in the selector ourselves.
|
||||
ruleNode.selector = ruleNode.selector.replace('&', groupSelector)
|
||||
walk([ruleNode], (node) => {
|
||||
if (node.kind !== 'rule') return WalkAction.Continue
|
||||
|
||||
// Use `:where` to make sure the specificity of group variants isn't higher
|
||||
// than the specificity of other variants.
|
||||
ruleNode.selector = `&:is(${ruleNode.selector} *)`
|
||||
// Skip past at-rules, and continue traversing the children of the at-rule
|
||||
if (node.selector[0] === '@') return WalkAction.Continue
|
||||
|
||||
// For most variants we rely entirely on CSS nesting to build-up the final
|
||||
// selector, but there is no way to use CSS nesting to make `&` refer to
|
||||
// just the `.group` class the way we'd need to for these variants, so we
|
||||
// need to replace it in the selector ourselves.
|
||||
node.selector = node.selector.replaceAll('&', groupSelector)
|
||||
|
||||
// When the selector is a selector _list_ we need to wrap it in `:is`
|
||||
// to make sure the matching behavior is consistent with the original
|
||||
// variant / selector.
|
||||
if (segment(node.selector, ',').length > 1) {
|
||||
node.selector = `:is(${node.selector})`
|
||||
}
|
||||
|
||||
// Use `:where` to make sure the specificity of group variants isn't higher
|
||||
// than the specificity of other variants.
|
||||
node.selector = `&:is(${node.selector} *)`
|
||||
})
|
||||
})
|
||||
|
||||
variants.suggest('group', () => {
|
||||
@ -214,15 +257,29 @@ export function createVariants(theme: Theme): Variants {
|
||||
? `:where(.peer\\/${variant.modifier.value})`
|
||||
: ':where(.peer)'
|
||||
|
||||
// For most variants we rely entirely on CSS nesting to build-up the final
|
||||
// selector, but there is no way to use CSS nesting to make `&` refer to
|
||||
// just the `.peer` class the way we'd need to for these variants, so we
|
||||
// need to replace it in the selector ourselves.
|
||||
ruleNode.selector = ruleNode.selector.replace('&', peerSelector)
|
||||
walk([ruleNode], (node) => {
|
||||
if (node.kind !== 'rule') return WalkAction.Continue
|
||||
|
||||
// Use `:where` to make sure the specificity of peer variants isn't higher
|
||||
// than the specificity of other variants.
|
||||
ruleNode.selector = `&:is(${ruleNode.selector} ~ *)`
|
||||
// Skip past at-rules, and continue traversing the children of the at-rule
|
||||
if (node.selector[0] === '@') return WalkAction.Continue
|
||||
|
||||
// For most variants we rely entirely on CSS nesting to build-up the final
|
||||
// selector, but there is no way to use CSS nesting to make `&` refer to
|
||||
// just the `.group` class the way we'd need to for these variants, so we
|
||||
// need to replace it in the selector ourselves.
|
||||
node.selector = node.selector.replaceAll('&', peerSelector)
|
||||
|
||||
// When the selector is a selector _list_ we need to wrap it in `:is`
|
||||
// to make sure the matching behavior is consistent with the original
|
||||
// variant / selector.
|
||||
if (segment(node.selector, ',').length > 1) {
|
||||
node.selector = `:is(${node.selector})`
|
||||
}
|
||||
|
||||
// Use `:where` to make sure the specificity of group variants isn't higher
|
||||
// than the specificity of other variants.
|
||||
node.selector = `&:is(${node.selector} ~ *)`
|
||||
})
|
||||
})
|
||||
|
||||
variants.suggest('peer', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user