Skip to content

Commit 618cbfc

Browse files
Add support for @custom-variant (#1127)
This adds support for `@custom-variant`, adds variant suggestions for `@variant`, and tweaks when at-rules are suggested such that only ones that are valid in the given context should be suggested (e.g. when at the root or when nested in some rule). This is the IntelliSense portion of tailwindlabs/tailwindcss#15663
1 parent e871fc9 commit 618cbfc

File tree

5 files changed

+152
-49
lines changed

5 files changed

+152
-49
lines changed

packages/tailwindcss-language-server/src/language/cssServer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise<void> {
396396
.filter((diagnostic) => {
397397
if (
398398
diagnostic.code === 'unknownAtRules' &&
399-
/Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant)/.test(
399+
/Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant|custom-variant)/.test(
400400
diagnostic.message,
401401
)
402402
) {

packages/tailwindcss-language-server/tests/completions/completions.test.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -477,15 +477,15 @@ withFixture('v4/basic', (c) => {
477477
expect(result.items.filter((item) => item.label.startsWith('--')).length).toBe(23)
478478
})
479479

480-
test.concurrent('@slot is suggeted inside @variant', async ({ expect }) => {
480+
test.concurrent('@slot is suggeted inside @custom-variant', async ({ expect }) => {
481481
let result = await completion({
482482
lang: 'css',
483483
text: '@',
484484
position: { line: 0, character: 1 },
485485
})
486486

487487
// Make sure `@slot` is NOT suggested by default
488-
expect(result.items.length).toBe(10)
488+
expect(result.items.length).toBe(7)
489489
expect(result.items).not.toEqual(
490490
expect.arrayContaining([
491491
expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }),
@@ -494,12 +494,12 @@ withFixture('v4/basic', (c) => {
494494

495495
result = await completion({
496496
lang: 'css',
497-
text: '@variant foo {\n@',
497+
text: '@custom-variant foo {\n@',
498498
position: { line: 1, character: 1 },
499499
})
500500

501501
// Make sure `@slot` is suggested
502-
expect(result.items.length).toBe(11)
502+
expect(result.items.length).toBe(4)
503503
expect(result.items).toEqual(
504504
expect.arrayContaining([
505505
expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }),

packages/tailwindcss-language-server/tests/fixtures/v4/invalid-import-order/tailwind.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This is invalid in this position because some `@import`s are not at the top of the file.
55
* We don't want project discovery to fail so we hoist them up and then warn in the console.
66
*/
7-
@variant dark (&:where(.dark, .dark *));
7+
@custom-variant dark (&:where(.dark, .dark *));
88

99
@import './a.css';
1010
@import './b.css';

packages/tailwindcss-language-service/src/completionProvider.ts

+143-43
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,66 @@ function provideVariantsDirectiveCompletions(
12751275
)
12761276
}
12771277

1278+
function provideVariantDirectiveCompletions(
1279+
state: State,
1280+
document: TextDocument,
1281+
position: Position,
1282+
): CompletionList {
1283+
if (!state.v4) return null
1284+
if (!isCssContext(state, document, position)) return null
1285+
1286+
let text = document.getText({
1287+
start: { line: position.line, character: 0 },
1288+
end: position,
1289+
})
1290+
1291+
let match = text.match(/^\s*@variant\s+(?<partial>[^}]*)$/i)
1292+
if (match === null) return null
1293+
1294+
let partial = match.groups.partial.trim()
1295+
1296+
// We only allow one variant `@variant` call
1297+
if (/\s/.test(partial)) return null
1298+
1299+
// We don't allow applying stacked variants so don't suggest them
1300+
if (/:/.test(partial)) return null
1301+
1302+
let possibleVariants = state.variants.flatMap((variant) => {
1303+
if (variant.values.length) {
1304+
return variant.values.map((value) =>
1305+
value === 'DEFAULT' ? variant.name : `${variant.name}${variant.hasDash ? '-' : ''}${value}`,
1306+
)
1307+
}
1308+
1309+
return [variant.name]
1310+
})
1311+
1312+
return withDefaults(
1313+
{
1314+
isIncomplete: false,
1315+
items: possibleVariants.map((variant, index, variants) => ({
1316+
label: variant,
1317+
kind: 21,
1318+
sortText: naturalExpand(index, variants.length),
1319+
})),
1320+
},
1321+
{
1322+
data: {
1323+
...(state.completionItemData ?? {}),
1324+
_type: 'variant',
1325+
},
1326+
range: {
1327+
start: {
1328+
line: position.line,
1329+
character: position.character,
1330+
},
1331+
end: position,
1332+
},
1333+
},
1334+
state.editor.capabilities.itemDefaults,
1335+
)
1336+
}
1337+
12781338
function provideLayerDirectiveCompletions(
12791339
state: State,
12801340
document: TextDocument,
@@ -1293,10 +1353,16 @@ function provideLayerDirectiveCompletions(
12931353

12941354
if (match === null) return null
12951355

1356+
let layerNames = ['base', 'components', 'utilities']
1357+
1358+
if (state.v4) {
1359+
layerNames = ['theme', 'base', 'components', 'utilities']
1360+
}
1361+
12961362
return withDefaults(
12971363
{
12981364
isIncomplete: false,
1299-
items: ['base', 'components', 'utilities'].map((layer, index, layers) => ({
1365+
items: layerNames.map((layer, index, layers) => ({
13001366
label: layer,
13011367
kind: 21,
13021368
sortText: naturalExpand(index, layers.length),
@@ -1423,42 +1489,53 @@ function provideCssDirectiveCompletions(
14231489

14241490
if (match === null) return null
14251491

1492+
let isNested = isInsideNesting(document, position)
1493+
14261494
let items: CompletionItem[] = []
14271495

1428-
items.push({
1429-
label: '@tailwind',
1430-
documentation: {
1431-
kind: 'markdown' as typeof MarkupKind.Markdown,
1432-
value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${
1433-
state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens'
1434-
}\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1435-
state.version,
1436-
'functions-and-directives/#tailwind',
1437-
)})`,
1438-
},
1439-
})
1496+
if (state.v4) {
1497+
// We don't suggest @tailwind anymore in v4 because we prefer that people
1498+
// use the imports instead
1499+
} else {
1500+
items.push({
1501+
label: '@tailwind',
1502+
documentation: {
1503+
kind: 'markdown' as typeof MarkupKind.Markdown,
1504+
value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${
1505+
state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens'
1506+
}\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1507+
state.version,
1508+
'functions-and-directives/#tailwind',
1509+
)})`,
1510+
},
1511+
})
1512+
}
14401513

1441-
items.push({
1442-
label: '@screen',
1443-
documentation: {
1444-
kind: 'markdown' as typeof MarkupKind.Markdown,
1445-
value: `The \`@screen\` directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1446-
state.version,
1447-
'functions-and-directives/#screen',
1448-
)})`,
1449-
},
1450-
})
1514+
if (!state.v4) {
1515+
items.push({
1516+
label: '@screen',
1517+
documentation: {
1518+
kind: 'markdown' as typeof MarkupKind.Markdown,
1519+
value: `The \`@screen\` directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1520+
state.version,
1521+
'functions-and-directives/#screen',
1522+
)})`,
1523+
},
1524+
})
1525+
}
14511526

1452-
items.push({
1453-
label: '@apply',
1454-
documentation: {
1455-
kind: 'markdown' as typeof MarkupKind.Markdown,
1456-
value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1457-
state.version,
1458-
'functions-and-directives/#apply',
1459-
)})`,
1460-
},
1461-
})
1527+
if (isNested) {
1528+
items.push({
1529+
label: '@apply',
1530+
documentation: {
1531+
kind: 'markdown' as typeof MarkupKind.Markdown,
1532+
value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1533+
state.version,
1534+
'functions-and-directives/#apply',
1535+
)})`,
1536+
},
1537+
})
1538+
}
14621539

14631540
if (semver.gte(state.version, '1.8.0')) {
14641541
items.push({
@@ -1498,7 +1575,7 @@ function provideCssDirectiveCompletions(
14981575
})
14991576
}
15001577

1501-
if (semver.gte(state.version, '3.2.0')) {
1578+
if (semver.gte(state.version, '3.2.0') && !isNested) {
15021579
items.push({
15031580
label: '@config',
15041581
documentation: {
@@ -1511,7 +1588,7 @@ function provideCssDirectiveCompletions(
15111588
})
15121589
}
15131590

1514-
if (state.v4) {
1591+
if (state.v4 && !isNested) {
15151592
items.push({
15161593
label: '@theme',
15171594
documentation: {
@@ -1535,12 +1612,12 @@ function provideCssDirectiveCompletions(
15351612
})
15361613

15371614
items.push({
1538-
label: '@variant',
1615+
label: '@custom-variant',
15391616
documentation: {
15401617
kind: 'markdown' as typeof MarkupKind.Markdown,
1541-
value: `Use the \`@variant\` directive to define a custom variant or override an existing one.\n\n[Tailwind CSS Documentation](${docsUrl(
1618+
value: `Use the \`@custom-variant\` directive to define a custom variant or override an existing one.\n\n[Tailwind CSS Documentation](${docsUrl(
15421619
state.version,
1543-
'functions-and-directives/#variant',
1620+
'functions-and-directives/#custom-variant',
15441621
)})`,
15451622
},
15461623
})
@@ -1566,9 +1643,22 @@ function provideCssDirectiveCompletions(
15661643
)})`,
15671644
},
15681645
})
1646+
}
1647+
1648+
if (state.v4 && isNested) {
1649+
items.push({
1650+
label: '@variant',
1651+
documentation: {
1652+
kind: 'markdown' as typeof MarkupKind.Markdown,
1653+
value: `Use the \`@variant\` directive to use a variant in CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
1654+
state.version,
1655+
'functions-and-directives/variant',
1656+
)})`,
1657+
},
1658+
})
15691659

1570-
// If we're inside an @variant directive, also add `@slot`
1571-
if (isInsideAtRule('variant', document, position)) {
1660+
// If we're inside an @custom-variant directive, also add `@slot`
1661+
if (isInsideAtRule('custom-variant', document, position)) {
15721662
items.push({
15731663
label: '@slot',
15741664
documentation: {
@@ -1611,20 +1701,29 @@ function provideCssDirectiveCompletions(
16111701
}
16121702

16131703
function isInsideAtRule(name: string, document: TextDocument, position: Position) {
1614-
// 1. Get all text up to the current position
16151704
let text = document.getText({
16161705
start: { line: 0, character: 0 },
16171706
end: position,
16181707
})
16191708

1620-
// 2. Find the last instance of the at-rule
1709+
// Find the last instance of the at-rule
16211710
let block = text.lastIndexOf(`@${name}`)
16221711
if (block === -1) return false
16231712

1624-
// 4. Count the number of open and close braces following the rule to determine if we're inside it
1713+
// Check if we're inside it by counting the number of still-open braces
16251714
return braceLevel(text.slice(block)) > 0
16261715
}
16271716

1717+
function isInsideNesting(document: TextDocument, position: Position) {
1718+
let text = document.getText({
1719+
start: { line: 0, character: 0 },
1720+
end: position,
1721+
})
1722+
1723+
// Check if we're inside a rule by counting the number of still-open braces
1724+
return braceLevel(text) > 0
1725+
}
1726+
16281727
// Provide completions for directives that take file paths
16291728
const PATTERN_AT_THEME = /@(?<directive>theme)\s+(?:(?<parts>[^{]+)\s$|$)/
16301729
const PATTERN_IMPORT_THEME = /@(?<directive>import)\s*[^;]+?theme\((?:(?<parts>[^)]+)\s$|$)/
@@ -1874,6 +1973,7 @@ export async function doComplete(
18741973
provideCssHelperCompletions(state, document, position) ||
18751974
provideCssDirectiveCompletions(state, document, position) ||
18761975
provideScreenDirectiveCompletions(state, document, position) ||
1976+
provideVariantDirectiveCompletions(state, document, position) ||
18771977
provideVariantsDirectiveCompletions(state, document, position) ||
18781978
provideTailwindDirectiveCompletions(state, document, position) ||
18791979
provideLayerDirectiveCompletions(state, document, position) ||

packages/vscode-tailwindcss/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
- Don't break when importing missing CSS files ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
66
- Resolve CSS imports as relative first ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
77
- Add TypeScript config path support in v4 CSS files ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
8+
- Add support for `@custom-variant` ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))
9+
- Add variant suggestions to `@variant` ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))
10+
- Don't suggest at-rules when nested that cannot be used in a nested context ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))
811

912
## 0.12.18
1013

0 commit comments

Comments
 (0)