From 9f8844f26cb7a0326f4b929990e926e6816e06af Mon Sep 17 00:00:00 2001 From: Tanuj Kanti Date: Sun, 2 Aug 2026 21:02:23 +0530 Subject: [PATCH 1/2] feat: check comma-seperated selectors also --- docs/rules/no-duplicate-keyframe-selectors.md | 26 ++ src/rules/no-duplicate-keyframe-selectors.js | 72 +++-- .../no-duplicate-keyframe-selectors.test.js | 256 ++++++++++++++++++ 3 files changed, 328 insertions(+), 26 deletions(-) diff --git a/docs/rules/no-duplicate-keyframe-selectors.md b/docs/rules/no-duplicate-keyframe-selectors.md index 37a8ce32..23045b17 100644 --- a/docs/rules/no-duplicate-keyframe-selectors.md +++ b/docs/rules/no-duplicate-keyframe-selectors.md @@ -39,6 +39,17 @@ Examples of **incorrect** code for this rule: } } +@keyframes test { + 0%, + 50% { + opacity: 0; + } + + 50% { + opacity: 1; + } +} + @keyframes test { from { opacity: 0; @@ -97,6 +108,21 @@ Examples of **correct** code for this rule: } } +@keyframes test { + 0% { + opacity: 0; + } + + 30%, + 60% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + @keyframes test { from { opacity: 0; diff --git a/src/rules/no-duplicate-keyframe-selectors.js b/src/rules/no-duplicate-keyframe-selectors.js index a69cc9d4..b68b7379 100644 --- a/src/rules/no-duplicate-keyframe-selectors.js +++ b/src/rules/no-duplicate-keyframe-selectors.js @@ -62,37 +62,57 @@ export default /** @satisfies {DuplicateKeyframeSelectorRuleDefinition} */ ({ } // @ts-ignore - children is a valid property for prelude - const selector = node.prelude.children[0]; + const selectors = node.prelude.children; const value = []; - selector.children.forEach(component => { - if (component.type === "Percentage") { - value.push(`${component.value}%`); - } else if (component.type === "TypeSelector") { - value.push(component.name.toLowerCase()); + selectors.forEach(selector => { + const component = selector.children[0]; + const componentType = component.type; + + if (selector.children.length === 1) { + if (componentType === "Percentage") { + value.push(`${component.value}%`); + } else if (componentType === "TypeSelector") { + value.push(component.name.toLowerCase()); + } + } else { + if ( + selector.children.some( + child => child.type === "Combinator", + ) + ) { + if ( + componentType === "TypeSelector" && + selector.children[1].type === "Combinator" && + selector.children[2].type === "Percentage" + ) { + value.push( + `${component.name.toLowerCase()} ${selector.children[2].value}%`, + ); + } + } } }); - const selectorValue = value.join(" "); - const key = value - .map( - selectorPart => - keyframeSelectorAliases.get(selectorPart) ?? - selectorPart, - ) - .join(" "); - - if (seen.has(key)) { - context.report({ - loc: selector.loc, - messageId: "duplicateKeyframeSelector", - data: { - selector: selectorValue, - }, - }); - } else { - seen.add(key); - } + const keys = value.map( + selectorPart => + keyframeSelectorAliases.get(selectorPart) ?? + selectorPart, + ); + + keys.forEach((key, i) => { + if (seen.has(key)) { + context.report({ + loc: selectors[i].loc, + messageId: "duplicateKeyframeSelector", + data: { + selector: value[i], + }, + }); + } else { + seen.add(key); + } + }); }, }; }, diff --git a/tests/rules/no-duplicate-keyframe-selectors.test.js b/tests/rules/no-duplicate-keyframe-selectors.test.js index 51ed68e8..8cbcb919 100644 --- a/tests/rules/no-duplicate-keyframe-selectors.test.js +++ b/tests/rules/no-duplicate-keyframe-selectors.test.js @@ -49,11 +49,21 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { 0% { opacity: 0; } 50% { opacity: 0.5; } 100% { opacity: 1; } + }`, + dedent`@keyframes test { + 0%, 20% { opacity: 0; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } }`, dedent`@keyframes test { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } + }`, + dedent`@keyframes test { + from, 30% { opacity: 0; } + 50% { opacity: 0.5; } + to { opacity: 1; } }`, dedent`@keyframes test { }`, @@ -82,6 +92,16 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { entry 100% { opacity: 1; } exit 0% { opacity: 1; } exit 100% { opacity: 0; } + }`, + dedent`@keyframes test { + entry 0%, 30% { opacity: 0; } + entry 100% { opacity: 1; } + exit 0%, 50% { opacity: 1; } + exit 100% { opacity: 0; } + }`, + dedent`@keyframes test { + entry 0%, 0% { opacity: 0; } + exit 100%, 100% { opacity: 1; } }`, ], invalid: [ @@ -352,6 +372,69 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@KEYFRAMES test { + 0% { opacity: 0; } + 0%, 50% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 7, + }, + ], + }, + { + code: dedent`@KEYFRAMES test { + 0%, 50% { opacity: 0; } + 50% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "50%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 8, + }, + ], + }, + { + code: dedent`@KEYFRAMES test { + 50% { opacity: 0; } + 0%, 50% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "50%" }, + line: 3, + column: 9, + endLine: 3, + endColumn: 12, + }, + ], + }, + { + code: dedent`@KEYFRAMES test { + 0%, 0% { top: 0; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 2, + column: 9, + endLine: 2, + endColumn: 11, + }, + ], + }, { code: dedent`@Keyframes test { 0% { @@ -466,6 +549,85 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@keyframes test { + entry 0% { opacity: 0; } + entry 0%, 50% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "entry 0%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 13, + }, + ], + }, + { + code: dedent`@keyframes test { + entry 0% { opacity: 0; } + 50%, entry 0% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "entry 0%" }, + line: 3, + column: 10, + endLine: 3, + endColumn: 18, + }, + ], + }, + { + code: dedent`@keyframes test { + entry 0%, Entry 0% { opacity: 0; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "entry 0%" }, + line: 2, + column: 15, + endLine: 2, + endColumn: 23, + }, + ], + }, + { + code: dedent`@keyframes test { + entry 0%, exit 100% { opacity: 0; } + exit 100% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "exit 100%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 14, + }, + ], + }, + { + code: dedent`@keyframes test { + entry 0%, Exit 100% { opacity: 0; } + exit 100% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "exit 100%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 14, + }, + ], + }, { code: dedent`@keyframes test { from { opacity: 0; } @@ -544,5 +706,99 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@keyframes test { + from, 0% { opacity: 0; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 2, + column: 11, + endLine: 2, + endColumn: 13, + }, + ], + }, + { + code: dedent`@keyframes test { + 0%, from { opacity: 0; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "from" }, + line: 2, + column: 9, + endLine: 2, + endColumn: 13, + }, + ], + }, + { + code: dedent`@keyframes test { + from { opacity: 0; } + 0%, 50% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 7, + }, + ], + }, + { + code: dedent`@keyframes test { + from { opacity: 0; } + to, 100% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "100%" }, + line: 3, + column: 9, + endLine: 3, + endColumn: 13, + }, + ], + }, + { + code: dedent`@keyframes test { + from, 100% { opacity: 0; } + to { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "to" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 7, + }, + ], + }, + { + code: dedent`@keyframes test { + from { opacity: 0; } + 100%, to { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "to" }, + line: 3, + column: 11, + endLine: 3, + endColumn: 13, + }, + ], + }, ], }); From c269dfc97bb34ed7409c49b3111728b4e9f09554 Mon Sep 17 00:00:00 2001 From: Tanuj Kanti Date: Mon, 3 Aug 2026 12:09:10 +0530 Subject: [PATCH 2/2] add tests with multiple selectors --- .../no-duplicate-keyframe-selectors.test.js | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/rules/no-duplicate-keyframe-selectors.test.js b/tests/rules/no-duplicate-keyframe-selectors.test.js index 8cbcb919..272b1a6e 100644 --- a/tests/rules/no-duplicate-keyframe-selectors.test.js +++ b/tests/rules/no-duplicate-keyframe-selectors.test.js @@ -54,6 +54,10 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { 0%, 20% { opacity: 0; } 50% { opacity: 0.5; } 100% { opacity: 1; } + }`, + dedent`@keyframes test { + 0%, 10%, 20% { opacity: 0; } + 50%, 100% { opacity: 1; } }`, dedent`@keyframes test { from { opacity: 0; } @@ -420,6 +424,22 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@KEYFRAMES test { + 0%, 50% { opacity: 0; } + 70%, 100%, 0% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 3, + column: 16, + endLine: 3, + endColumn: 18, + }, + ], + }, { code: dedent`@KEYFRAMES test { 0%, 0% { top: 0; } @@ -581,6 +601,22 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@keyframes test { + entry 0% { opacity: 0; } + 50%, 100%, entry 0% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "entry 0%" }, + line: 3, + column: 16, + endLine: 3, + endColumn: 24, + }, + ], + }, { code: dedent`@keyframes test { entry 0%, Entry 0% { opacity: 0; } @@ -612,6 +648,22 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@keyframes test { + entry 0%, exit 100% { opacity: 0; } + 50%, 100%, exit 100% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "exit 100%" }, + line: 3, + column: 16, + endLine: 3, + endColumn: 25, + }, + ], + }, { code: dedent`@keyframes test { entry 0%, Exit 100% { opacity: 0; } @@ -755,6 +807,22 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { { code: dedent`@keyframes test { from { opacity: 0; } + 50%, 100%, 0% { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "0%" }, + line: 3, + column: 16, + endLine: 3, + endColumn: 18, + }, + ], + }, + { + code: dedent`@keyframes test { + from { opacity: 0; } to, 100% { opacity: 1; } }`, errors: [ @@ -800,5 +868,21 @@ ruleTester.run("no-duplicate-keyframe-selectors", rule, { }, ], }, + { + code: dedent`@keyframes test { + from { opacity: 0; } + 50%, 100%, to { opacity: 1; } + }`, + errors: [ + { + messageId: "duplicateKeyframeSelector", + data: { selector: "to" }, + line: 3, + column: 16, + endLine: 3, + endColumn: 18, + }, + ], + }, ], });