Skip to content

Add support for @custom-variant #1127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ async function validateTextDocument(textDocument: TextDocument): Promise<void> {
.filter((diagnostic) => {
if (
diagnostic.code === 'unknownAtRules' &&
/Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant)/.test(
/Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant|custom-variant)/.test(
diagnostic.message,
)
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,15 +477,15 @@ withFixture('v4/basic', (c) => {
expect(result.items.filter((item) => item.label.startsWith('--')).length).toBe(23)
})

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

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

result = await completion({
lang: 'css',
text: '@variant foo {\n@',
text: '@custom-variant foo {\n@',
position: { line: 1, character: 1 },
})

// Make sure `@slot` is suggested
expect(result.items.length).toBe(11)
expect(result.items.length).toBe(4)
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This is invalid in this position because some `@import`s are not at the top of the file.
* We don't want project discovery to fail so we hoist them up and then warn in the console.
*/
@variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where(.dark, .dark *));

@import './a.css';
@import './b.css';
Expand Down
186 changes: 143 additions & 43 deletions packages/tailwindcss-language-service/src/completionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,66 @@ function provideVariantsDirectiveCompletions(
)
}

function provideVariantDirectiveCompletions(
state: State,
document: TextDocument,
position: Position,
): CompletionList {
if (!state.v4) return null
if (!isCssContext(state, document, position)) return null

let text = document.getText({
start: { line: position.line, character: 0 },
end: position,
})

let match = text.match(/^\s*@variant\s+(?<partial>[^}]*)$/i)
if (match === null) return null

let partial = match.groups.partial.trim()

// We only allow one variant `@variant` call
if (/\s/.test(partial)) return null

// We don't allow applying stacked variants so don't suggest them
if (/:/.test(partial)) return null

let possibleVariants = state.variants.flatMap((variant) => {
if (variant.values.length) {
return variant.values.map((value) =>
value === 'DEFAULT' ? variant.name : `${variant.name}${variant.hasDash ? '-' : ''}${value}`,
)
}

return [variant.name]
})

return withDefaults(
{
isIncomplete: false,
items: possibleVariants.map((variant, index, variants) => ({
label: variant,
kind: 21,
sortText: naturalExpand(index, variants.length),
})),
},
{
data: {
...(state.completionItemData ?? {}),
_type: 'variant',
},
range: {
start: {
line: position.line,
character: position.character,
},
end: position,
},
},
state.editor.capabilities.itemDefaults,
)
}

function provideLayerDirectiveCompletions(
state: State,
document: TextDocument,
Expand All @@ -1293,10 +1353,16 @@ function provideLayerDirectiveCompletions(

if (match === null) return null

let layerNames = ['base', 'components', 'utilities']

if (state.v4) {
layerNames = ['theme', 'base', 'components', 'utilities']
}

return withDefaults(
{
isIncomplete: false,
items: ['base', 'components', 'utilities'].map((layer, index, layers) => ({
items: layerNames.map((layer, index, layers) => ({
label: layer,
kind: 21,
sortText: naturalExpand(index, layers.length),
Expand Down Expand Up @@ -1423,42 +1489,53 @@ function provideCssDirectiveCompletions(

if (match === null) return null

let isNested = isInsideNesting(document, position)

let items: CompletionItem[] = []

items.push({
label: '@tailwind',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${
state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens'
}\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#tailwind',
)})`,
},
})
if (state.v4) {
// We don't suggest @tailwind anymore in v4 because we prefer that people
// use the imports instead
} else {
items.push({
label: '@tailwind',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use the \`@tailwind\` directive to insert Tailwind’s \`base\`, \`components\`, \`utilities\` and \`${
state.jit && semver.gte(state.version, '2.1.99') ? 'variants' : 'screens'
}\` styles into your CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#tailwind',
)})`,
},
})
}

items.push({
label: '@screen',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
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(
state.version,
'functions-and-directives/#screen',
)})`,
},
})
if (!state.v4) {
items.push({
label: '@screen',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
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(
state.version,
'functions-and-directives/#screen',
)})`,
},
})
}

items.push({
label: '@apply',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#apply',
)})`,
},
})
if (isNested) {
items.push({
label: '@apply',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use \`@apply\` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#apply',
)})`,
},
})
}

if (semver.gte(state.version, '1.8.0')) {
items.push({
Expand Down Expand Up @@ -1498,7 +1575,7 @@ function provideCssDirectiveCompletions(
})
}

if (semver.gte(state.version, '3.2.0')) {
if (semver.gte(state.version, '3.2.0') && !isNested) {
items.push({
label: '@config',
documentation: {
Expand All @@ -1511,7 +1588,7 @@ function provideCssDirectiveCompletions(
})
}

if (state.v4) {
if (state.v4 && !isNested) {
items.push({
label: '@theme',
documentation: {
Expand All @@ -1535,12 +1612,12 @@ function provideCssDirectiveCompletions(
})

items.push({
label: '@variant',
label: '@custom-variant',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use the \`@variant\` directive to define a custom variant or override an existing one.\n\n[Tailwind CSS Documentation](${docsUrl(
value: `Use the \`@custom-variant\` directive to define a custom variant or override an existing one.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/#variant',
'functions-and-directives/#custom-variant',
)})`,
},
})
Expand All @@ -1566,9 +1643,22 @@ function provideCssDirectiveCompletions(
)})`,
},
})
}

if (state.v4 && isNested) {
items.push({
label: '@variant',
documentation: {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: `Use the \`@variant\` directive to use a variant in CSS.\n\n[Tailwind CSS Documentation](${docsUrl(
state.version,
'functions-and-directives/variant',
)})`,
},
})

// If we're inside an @variant directive, also add `@slot`
if (isInsideAtRule('variant', document, position)) {
// If we're inside an @custom-variant directive, also add `@slot`
if (isInsideAtRule('custom-variant', document, position)) {
items.push({
label: '@slot',
documentation: {
Expand Down Expand Up @@ -1611,20 +1701,29 @@ function provideCssDirectiveCompletions(
}

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

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

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

function isInsideNesting(document: TextDocument, position: Position) {
let text = document.getText({
start: { line: 0, character: 0 },
end: position,
})

// Check if we're inside a rule by counting the number of still-open braces
return braceLevel(text) > 0
}

// Provide completions for directives that take file paths
const PATTERN_AT_THEME = /@(?<directive>theme)\s+(?:(?<parts>[^{]+)\s$|$)/
const PATTERN_IMPORT_THEME = /@(?<directive>import)\s*[^;]+?theme\((?:(?<parts>[^)]+)\s$|$)/
Expand Down Expand Up @@ -1874,6 +1973,7 @@ export async function doComplete(
provideCssHelperCompletions(state, document, position) ||
provideCssDirectiveCompletions(state, document, position) ||
provideScreenDirectiveCompletions(state, document, position) ||
provideVariantDirectiveCompletions(state, document, position) ||
provideVariantsDirectiveCompletions(state, document, position) ||
provideTailwindDirectiveCompletions(state, document, position) ||
provideLayerDirectiveCompletions(state, document, position) ||
Expand Down
3 changes: 3 additions & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- Don't break when importing missing CSS files ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
- Resolve CSS imports as relative first ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
- Add TypeScript config path support in v4 CSS files ([#1106](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1106))
- Add support for `@custom-variant` ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))
- Add variant suggestions to `@variant` ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))
- Don't suggest at-rules when nested that cannot be used in a nested context ([#1127](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1127))

## 0.12.18

Expand Down