From c242e9bdd509a8c6e05fef74a910e82d8ada351b Mon Sep 17 00:00:00 2001 From: Gaic4o <52266597+Gaic4o@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:38:36 +0900 Subject: [PATCH 1/2] feat: add no-unknown-animations rule fixes #529 --- README.md | 1 + docs/rules/no-unknown-animations.md | 99 +++++ src/rules/no-unknown-animations.js | 141 ++++++++ tests/rules/no-unknown-animations.test.js | 423 ++++++++++++++++++++++ 4 files changed, 664 insertions(+) create mode 100644 docs/rules/no-unknown-animations.md create mode 100644 src/rules/no-unknown-animations.js create mode 100644 tests/rules/no-unknown-animations.test.js diff --git a/README.md b/README.md index e011e01a..44a45d54 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ export default defineConfig([ | [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes | | [`no-invalid-named-grid-areas`](./docs/rules/no-invalid-named-grid-areas.md) | Disallow invalid named grid areas | yes | | [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes | +| [`no-unknown-animations`](./docs/rules/no-unknown-animations.md) | Disallow unknown animation names | no | | [`no-unmatchable-selectors`](./docs/rules/no-unmatchable-selectors.md) | Disallow unmatchable selectors | yes | | [`prefer-logical-properties`](./docs/rules/prefer-logical-properties.md) | Enforce the use of logical properties | no | | [`relative-font-units`](./docs/rules/relative-font-units.md) | Enforce the use of relative font units | no | diff --git a/docs/rules/no-unknown-animations.md b/docs/rules/no-unknown-animations.md new file mode 100644 index 00000000..72d94140 --- /dev/null +++ b/docs/rules/no-unknown-animations.md @@ -0,0 +1,99 @@ +# no-unknown-animations + +Disallow unknown animation names. + +## Background + +CSS animations are created by assigning a [`@keyframes`](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes) rule's name to the [`animation-name`](https://developer.mozilla.org/en-US-US/docs/Web/CSS/animation-name) property or the [`animation`](https://developer.mozilla.org/en-US/docs/Web/CSS/animation) shorthand property, as in this example: + +```css +.card { + animation: fade-in 300ms ease; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} +``` + +If an animation name doesn't match any `@keyframes` rule, for example because of a typo or because the `@keyframes` rule was renamed or removed, the animation silently fails to run without any error. + +## Rule Details + +This rule warns when an animation name used in `animation` or `animation-name` doesn't match any `@keyframes` rule defined in the same source. + +Animation names are case-sensitive, and quoted and unquoted names refer to the same animation, so `animation-name: "fade-in"` matches `@keyframes fade-in`. + +The rule only checks statically determinable animation names. Dynamic animation names, such as those using `var()`, are ignored. + +Examples of **incorrect** code for this rule: + +```css +/* eslint css/no-unknown-animations: "error" */ + +.card { + animation: fade-in 300ms ease; +} + +.button { + animation-name: slide-up; +} + +@keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} +``` + +Examples of **correct** code for this rule: + +```css +/* eslint css/no-unknown-animations: "error" */ + +.card { + animation: fade-in 300ms ease; +} + +.button { + animation-name: slide-up; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slide-up { + from { + transform: translateY(8px); + } + + to { + transform: translateY(0); + } +} +``` + +## When Not to Use It + +Animations can reference `@keyframes` rules defined in another stylesheet, but this rule only checks `@keyframes` rules defined in the same source. If your `@keyframes` rules are defined separately from where the animations are used, you should not use this rule. + +## Prior Art + +- [`no-unknown-animations`](https://stylelint.io/user-guide/rules/no-unknown-animations/) diff --git a/src/rules/no-unknown-animations.js b/src/rules/no-unknown-animations.js new file mode 100644 index 00000000..2925451c --- /dev/null +++ b/src/rules/no-unknown-animations.js @@ -0,0 +1,141 @@ +/** + * @fileoverview Rule to disallow unknown animation names. + * @author Gaic4o + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @import { CSSRuleDefinition } from "../types.js" + * @import { CssLocationRange } from "@eslint/css-tree" + * @typedef {"unknownAnimation"} NoUnknownAnimationsMessageIds + * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoUnknownAnimationsMessageIds }>} NoUnknownAnimationsRuleDefinition + */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const animationPropertyPattern = /^animation(?:-name)?$/iu; + +/** + * Extracts an animation name from a node. Quoted and unquoted animation + * names refer to the same animation, so `"fade-in"` and `fade-in` both + * yield `fade-in`. + * @param {Object} node The node to extract the animation name from. + * @returns {string|null} The animation name, or `null` if the node isn't a name. + */ +function getAnimationName(node) { + if (node.type === "Identifier") { + return node.name; + } + + if (node.type === "String") { + return node.value; + } + + return null; +} + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({ + meta: { + type: "problem", + + docs: { + description: "Disallow unknown animation names", + recommended: false, + url: "https://github.com/eslint/css/blob/main/docs/rules/no-unknown-animations.md", + }, + + messages: { + unknownAnimation: "Unknown animation name '{{name}}' found.", + }, + }, + + create(context) { + const lexer = context.sourceCode.lexer; + + /** @type {Set} */ + const definedAnimations = new Set(); + + /** @type {Array<{ name: string, loc: CssLocationRange }>} */ + const usedAnimations = []; + + return { + "Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i] > AtrulePrelude"( + node, + ) { + const child = node.children[0]; + const name = child ? getAnimationName(child) : null; + + if (name !== null) { + definedAnimations.add(name); + } + }, + + "Rule > Block Declaration"(node) { + if ( + !animationPropertyPattern.test(node.property) || + node.value.type !== "Value" + ) { + return; + } + + const matchResult = lexer.matchProperty( + node.property, + node.value, + ); + + /* + * If the value can't be matched against the property grammar, + * its animation name can't be determined reliably. This + * includes dynamic values such as var(). Invalid property + * values are outside the scope of this rule. + */ + if (matchResult.error) { + return; + } + + for (const child of node.value.children) { + if (!matchResult.isType(child, "keyframes-name")) { + continue; + } + + const name = getAnimationName(child); + + if (name !== null) { + usedAnimations.push({ + name, + loc: child.loc, + }); + } + } + }, + + /* + * Usages are reported only after the entire stylesheet has been + * visited so that `@keyframes` rules defined after their usage + * are still found. + */ + "StyleSheet:exit"() { + for (const { name, loc } of usedAnimations) { + if (definedAnimations.has(name)) { + continue; + } + + context.report({ + loc, + messageId: "unknownAnimation", + data: { name }, + }); + } + }, + }; + }, +}); diff --git a/tests/rules/no-unknown-animations.test.js b/tests/rules/no-unknown-animations.test.js new file mode 100644 index 00000000..d137427e --- /dev/null +++ b/tests/rules/no-unknown-animations.test.js @@ -0,0 +1,423 @@ +/** + * @fileoverview Tests for no-unknown-animations rule. + * @author Gaic4o + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-unknown-animations.js"; +import css from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + css, + }, + language: "css/css", +}); + +ruleTester.run("no-unknown-animations", rule, { + valid: [ + "a { color: red; }", + dedent` + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + .a { animation: fade-in 300ms ease; } + `, + // @keyframes defined after usage + dedent` + .a { animation: fade-in 300ms ease; } + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // multiple animations + dedent` + .a { animation: fade-in 300ms ease, slide-up 1s infinite; } + @keyframes fade-in { + to { opacity: 1; } + } + @keyframes slide-up { + to { transform: translateY(0); } + } + `, + dedent` + .a { animation-name: fade-in, slide-up; } + @keyframes fade-in { + to { opacity: 1; } + } + @keyframes slide-up { + to { transform: translateY(0); } + } + `, + // vendor-prefixed @keyframes + dedent` + .a { animation-name: fade-in; } + @-webkit-keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @-moz-keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @-o-keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @KEYFRAMES fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @-WEBKIT-KEYFRAMES fade-in { + to { opacity: 1; } + } + `, + // quoted and unquoted names refer to the same animation + dedent` + .a { animation-name: "fade-in"; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in; } + @keyframes "fade-in" { + to { opacity: 1; } + } + `, + // case-insensitive properties + dedent` + .a { ANIMATION-NAME: fade-in; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // @keyframes inside a conditional at-rule + dedent` + .a { animation-name: fade-in; } + @media (prefers-reduced-motion: no-preference) { + @keyframes fade-in { + to { opacity: 1; } + } + } + `, + // usage inside nested rules and at-rules + dedent` + .a { + .b { animation-name: fade-in; } + } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + @media (min-width: 100px) { + .a { animation: fade-in 1s; } + } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // declarations directly inside a nested at-rule + dedent` + .a { + @media (min-width: 100px) { + animation-name: fade-in; + } + } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // `none` as a string is a valid animation name + dedent` + .a { animation-name: "none"; } + @keyframes "none" { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: fade-in !important; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // keywords are not animation names + ".a { animation: none; }", + ".a { animation-name: none; }", + ".a { animation-name: none, none; }", + ".a { animation-name: inherit; }", + ".a { animation-name: INHERIT; }", + ".a { animation-name: initial; }", + ".a { animation-name: unset; }", + ".a { animation-name: revert; }", + ".a { animation-name: revert-layer; }", + ".a { animation: 2s ease-in 1s infinite alternate; }", + // dynamic values can't be statically analyzed + ".a { animation: var(--anim) 1s; }", + ".a { animation-name: var(--anim-name); }", + // invalid values are reported by no-invalid-properties + ".a { animation-name: 100px; }", + ".a { animation-name: (); }", + // animation names are extracted only from animation and animation-name + ".a { --animation-name: fade-in; }", + ".a { transition-property: fade-in; }", + ".a { -webkit-animation-name: fade-in; }", + // feature queries don't use animations + "@supports (animation-name: fade-in) { .a { color: red; } }", + ], + invalid: [ + { + code: ".a { animation-name: fade-in !important; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, + { + code: '.a { animation-name: "none"; }', + errors: [ + { + messageId: "unknownAnimation", + data: { name: "none" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 28, + }, + ], + }, + { + code: ".a { animation-name: /* c */ fade-in; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 30, + endLine: 1, + endColumn: 37, + }, + ], + }, + { + code: ".a { animation-name: fade-in, fade-in; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 31, + endLine: 1, + endColumn: 38, + }, + ], + }, + { + code: ".a { animation-name: fade-in; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, + { + code: dedent` + .card { animation: fade-in 300ms ease; } + .button { animation-name: slide-up; } + @keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 20, + endLine: 1, + endColumn: 27, + }, + { + messageId: "unknownAnimation", + data: { name: "slide-up" }, + line: 2, + column: 27, + endLine: 2, + endColumn: 35, + }, + ], + }, + { + code: dedent` + .a { animation: fade-in 300ms ease, slide-up 1s infinite; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "slide-up" }, + line: 1, + column: 37, + endLine: 1, + endColumn: 45, + }, + ], + }, + { + code: dedent` + .a { animation-name: fade-in, slide-up; } + @keyframes slide-up { + to { transform: translateY(0); } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, + // animation names are case-sensitive + { + code: dedent` + .a { animation-name: FADE-IN; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "FADE-IN" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, + { + code: dedent` + .a { animation-name: "fade-in"; } + @keyframes fade-out { + to { opacity: 0; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 31, + }, + ], + }, + { + code: dedent` + @media (min-width: 100px) { + .a { animation: fade-in 1s; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 2, + column: 18, + endLine: 2, + endColumn: 25, + }, + ], + }, + { + code: dedent` + .a { + @media (min-width: 100px) { + animation-name: fade-in; + } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 3, + column: 19, + endLine: 3, + endColumn: 26, + }, + ], + }, + { + code: dedent` + .a { + .b { animation-name: fade-in; } + } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 2, + column: 23, + endLine: 2, + endColumn: 30, + }, + ], + }, + ], +}); From 0d95fdedcf22e4f3e211d0d90ca8b01e4d8acd63 Mon Sep 17 00:00:00 2001 From: Gaic4o <52266597+Gaic4o@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:51:23 +0900 Subject: [PATCH 2/2] feat: check vendor-prefixed animation properties and var() fallbacks --- docs/rules/no-unknown-animations.md | 15 +- src/rules/no-unknown-animations.js | 196 ++++++++++++++++++---- tests/rules/no-unknown-animations.test.js | 149 +++++++++++++++- 3 files changed, 326 insertions(+), 34 deletions(-) diff --git a/docs/rules/no-unknown-animations.md b/docs/rules/no-unknown-animations.md index 72d94140..c9109ffa 100644 --- a/docs/rules/no-unknown-animations.md +++ b/docs/rules/no-unknown-animations.md @@ -26,11 +26,22 @@ If an animation name doesn't match any `@keyframes` rule, for example because of ## Rule Details -This rule warns when an animation name used in `animation` or `animation-name` doesn't match any `@keyframes` rule defined in the same source. +This rule warns when an animation name used in `animation` or `animation-name` doesn't match any `@keyframes` rule defined in the same source. Vendor-prefixed properties such as `-webkit-animation` are checked too, and a vendor-prefixed `@keyframes` rule defines an animation name just like an unprefixed one does. Animation names are case-sensitive, and quoted and unquoted names refer to the same animation, so `animation-name: "fade-in"` matches `@keyframes fade-in`. -The rule only checks statically determinable animation names. Dynamic animation names, such as those using `var()`, are ignored. +The rule only checks statically determinable animation names. A `var()` contributes the animation name in its fallback, if it has one, and the rest of the value is checked either way: + +```css +/* `fade-in` is checked, the duration is not */ +animation: fade-in var(--duration); + +/* the fallback names an animation, so `slide-in` is checked */ +animation-name: var(--animation-name, slide-in); + +/* no name can be determined, so nothing is checked */ +animation-name: var(--animation-name); +``` Examples of **incorrect** code for this rule: diff --git a/src/rules/no-unknown-animations.js b/src/rules/no-unknown-animations.js index 2925451c..88c621a7 100644 --- a/src/rules/no-unknown-animations.js +++ b/src/rules/no-unknown-animations.js @@ -3,6 +3,12 @@ * @author Gaic4o */ +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { parse } from "@eslint/css-tree"; + //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- @@ -18,7 +24,8 @@ // Helpers //----------------------------------------------------------------------------- -const animationPropertyPattern = /^animation(?:-name)?$/iu; +const animationPropertyPattern = + /^(?:-(?:o|moz|webkit)-)?animation(?:-name)?$/iu; /** * Extracts an animation name from a node. Quoted and unquoted animation @@ -39,6 +46,81 @@ function getAnimationName(node) { return null; } +/** + * Returns the children of a node as an array. Nodes coming from the rule's + * AST store children in an array, while nodes produced by `parse()` store + * them in a list. + * @param {Object} node The node to read the children of. + * @returns {Array} The children of the node. + */ +function getChildren(node) { + const { children } = node; + + if (!children) { + return []; + } + + return Array.isArray(children) ? children : children.toArray(); +} + +/** + * Finds every `var()` function inside a node. + * @param {Object} node The node to search. + * @param {Array} varFunctions The array to collect the functions into. + * @returns {Array} The `var()` functions found. + */ +function findVarFunctions(node, varFunctions) { + for (const child of getChildren(node)) { + if (child.type === "Function" && child.name.toLowerCase() === "var") { + varFunctions.push(child); + } + + findVarFunctions(child, varFunctions); + } + + return varFunctions; +} + +/** + * Replaces every `var()` with whitespace, keeping any fallback value where it + * was. Because the replacement is the same length as the text it replaces, + * the remaining value keeps the offsets it has in the original source. + * @param {string} text The value text to mask. + * @param {number} baseOffset The offset at which `text` starts in the source. + * @param {Array} varFunctions The `var()` functions to mask. + * @returns {string} The masked value text. + */ +function maskVarFunctions(text, baseOffset, varFunctions) { + /** @type {Array<[number, number]>} */ + const ranges = []; + + for (const varFunction of varFunctions) { + const start = varFunction.loc.start.offset - baseOffset; + const end = varFunction.loc.end.offset - baseOffset; + const fallback = getChildren(varFunction).find( + child => child.type === "Raw", + ); + + if (fallback) { + ranges.push([start, fallback.loc.start.offset - baseOffset]); + ranges.push([fallback.loc.end.offset - baseOffset, end]); + } else { + ranges.push([start, end]); + } + } + + let masked = text; + + for (const [start, end] of ranges) { + masked = + masked.slice(0, start) + + " ".repeat(end - start) + + masked.slice(end); + } + + return masked; +} + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -59,7 +141,8 @@ export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({ }, create(context) { - const lexer = context.sourceCode.lexer; + const { sourceCode } = context; + const { lexer } = sourceCode; /** @type {Set} */ const definedAnimations = new Set(); @@ -67,11 +150,90 @@ export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({ /** @type {Array<{ name: string, loc: CssLocationRange }>} */ const usedAnimations = []; + /** + * Finds the animation names that a declaration value refers to. Only + * names that can be determined statically are returned, so a value + * such as `var(--name)` contributes no name while the fallback in + * `var(--name, slide-in)` does. + * @param {string} property The property the value belongs to. + * @param {Object} value The value node to search. + * @returns {Array<{ name: string, loc: CssLocationRange }>} The names found. + */ + function findAnimationNames(property, value) { + let valueNode = value; + let matchResult = lexer.matchProperty(property, valueNode); + + if (matchResult.error) { + let varFunctions = findVarFunctions(valueNode, []); + + /* + * A value that doesn't match the property grammar for any + * other reason is an invalid value, which is outside the + * scope of this rule. + */ + if (varFunctions.length === 0) { + return []; + } + + const baseOffset = valueNode.loc.start.offset; + const { line, column } = valueNode.loc.start; + let text = sourceCode.getText(value); + + /* + * Masking replaces a `var()` with its fallback, which may + * contain another `var()`, so keep masking until none are + * left. Each pass removes at least one `var()`, so this + * always terminates. + */ + while (varFunctions.length > 0) { + text = maskVarFunctions(text, baseOffset, varFunctions); + valueNode = parse(text, { + context: "value", + positions: true, + offset: baseOffset, + line, + column, + }); + varFunctions = findVarFunctions(valueNode, []); + } + + matchResult = lexer.matchProperty(property, valueNode); + + if (matchResult.error) { + return []; + } + } + + const names = []; + + for (const child of getChildren(valueNode)) { + if (!matchResult.isType(child, "keyframes-name")) { + continue; + } + + /* + * The lexer only matches an identifier or a string as a + * keyframes name, so a name is always found here. + */ + names.push({ + name: /** @type {string} */ (getAnimationName(child)), + loc: child.loc, + }); + } + + return names; + } + return { "Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i] > AtrulePrelude"( node, ) { const child = node.children[0]; + + /* + * A prelude that isn't an identifier or a string, such as the + * one in `@keyframes 50%`, doesn't name an animation. + */ const name = child ? getAnimationName(child) : null; if (name !== null) { @@ -87,35 +249,9 @@ export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({ return; } - const matchResult = lexer.matchProperty( - node.property, - node.value, + usedAnimations.push( + ...findAnimationNames(node.property, node.value), ); - - /* - * If the value can't be matched against the property grammar, - * its animation name can't be determined reliably. This - * includes dynamic values such as var(). Invalid property - * values are outside the scope of this rule. - */ - if (matchResult.error) { - return; - } - - for (const child of node.value.children) { - if (!matchResult.isType(child, "keyframes-name")) { - continue; - } - - const name = getAnimationName(child); - - if (name !== null) { - usedAnimations.push({ - name, - loc: child.loc, - }); - } - } }, /* diff --git a/tests/rules/no-unknown-animations.test.js b/tests/rules/no-unknown-animations.test.js index d137427e..ac83472b 100644 --- a/tests/rules/no-unknown-animations.test.js +++ b/tests/rules/no-unknown-animations.test.js @@ -178,20 +178,165 @@ ruleTester.run("no-unknown-animations", rule, { ".a { animation-name: revert; }", ".a { animation-name: revert-layer; }", ".a { animation: 2s ease-in 1s infinite alternate; }", - // dynamic values can't be statically analyzed + // vendor-prefixed animation properties + dedent` + .a { -webkit-animation-name: fade-in; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { -moz-animation: fade-in 1s; } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { -o-animation-name: fade-in; } + @-o-keyframes fade-in { + to { opacity: 1; } + } + `, + // -ms- is not checked, matching the prefixes the @keyframes check uses + ".a { -ms-animation-name: fade-in; }", + // names that can't be determined statically are ignored ".a { animation: var(--anim) 1s; }", ".a { animation-name: var(--anim-name); }", + ".a { animation: var(--anim); }", + // names that remain determinable next to a var() are still checked + dedent` + .a { animation: fade-in var(--duration); } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: var(--anim-name, fade-in); } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation: var(--anim, fade-in 1s ease); } + @keyframes fade-in { + to { opacity: 1; } + } + `, + dedent` + .a { animation-name: var(--a, var(--b, fade-in)); } + @keyframes fade-in { + to { opacity: 1; } + } + `, + // a fallback that isn't an animation name contributes none + ".a { animation: var(--duration, 1s); }", + // @keyframes preludes that don't name an animation + "@keyframes 50% { to { opacity: 1; } }", + "@keyframes 1s { to { opacity: 1; } }", // invalid values are reported by no-invalid-properties ".a { animation-name: 100px; }", ".a { animation-name: (); }", // animation names are extracted only from animation and animation-name ".a { --animation-name: fade-in; }", ".a { transition-property: fade-in; }", - ".a { -webkit-animation-name: fade-in; }", // feature queries don't use animations "@supports (animation-name: fade-in) { .a { color: red; } }", ], invalid: [ + { + code: ".a { -webkit-animation-name: fade-in; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 30, + endLine: 1, + endColumn: 37, + }, + ], + }, + { + code: ".a { -moz-animation: fade-in 1s; }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, + { + code: '.a { animation-name: var(--anim-name, "slide-in"); }', + errors: [ + { + messageId: "unknownAnimation", + data: { name: "slide-in" }, + line: 1, + column: 39, + endLine: 1, + endColumn: 49, + }, + ], + }, + { + code: ".a { animation: fade-in var(--duration); }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 17, + endLine: 1, + endColumn: 24, + }, + ], + }, + { + code: ".a { animation: var(--anim, fade-in 1s ease); }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 29, + endLine: 1, + endColumn: 36, + }, + ], + }, + { + code: ".a { animation-name: var(--a, var(--b, slide-in)); }", + errors: [ + { + messageId: "unknownAnimation", + data: { name: "slide-in" }, + line: 1, + column: 40, + endLine: 1, + endColumn: 48, + }, + ], + }, + { + code: dedent` + .a { animation-name: fade-in; } + @keyframes 50% { to { opacity: 1; } } + `, + errors: [ + { + messageId: "unknownAnimation", + data: { name: "fade-in" }, + line: 1, + column: 22, + endLine: 1, + endColumn: 29, + }, + ], + }, { code: ".a { animation-name: fade-in !important; }", errors: [