From 9c7fd6aa06b62c70d979ff5f7b9c06a95fdeed56 Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Tue, 25 Feb 2025 15:48:20 -0500 Subject: [PATCH] feat: add selector support to require-baseline (#61) * feat: add selector support Fixes #60 * review: apply suggested changes * feat: ignore selectors tested in @supports rules * docs: valid selector with supports --- docs/rules/require-baseline.md | 13 ++++ src/rules/require-baseline.js | 93 ++++++++++++++++++++++++++++ tests/rules/require-baseline.test.js | 57 +++++++++++++++++ 3 files changed, 163 insertions(+) diff --git a/docs/rules/require-baseline.md b/docs/rules/require-baseline.md index 1f5adcb..fdc53d6 100644 --- a/docs/rules/require-baseline.md +++ b/docs/rules/require-baseline.md @@ -23,6 +23,7 @@ This rule warns when it finds any of the following: - A media condition inside `@media` that isn't widely available. - A CSS property value that isn't widely available or otherwise isn't enclosed in a `@supports` block (currently limited to identifiers only). - A CSS property function that isn't widely available. +- A CSS pseudo-element or pseudo-class selector that isn't widely available. The data is provided via the [web-features](https://npmjs.com/package/web-features) package. @@ -39,6 +40,18 @@ a { width: abs(20% - 100px); } +/* invalid - :has() is not widely available */ +h1:has(+ h2) { + margin: 0 0 0.25rem 0; +} + +/* valid - @supports indicates you're choosing a limited availability selector */ +@supports selector(:has()) { + h1:has(+ h2) { + margin: 0 0 0.25rem 0; + } +} + /* invalid - device-posture is not widely available */ @media (device-posture: folded) { a { diff --git a/src/rules/require-baseline.js b/src/rules/require-baseline.js index 6b9d90b..8b66e27 100644 --- a/src/rules/require-baseline.js +++ b/src/rules/require-baseline.js @@ -15,6 +15,7 @@ import { atRules, mediaConditions, types, + selectors, } from "../data/baseline-data.js"; import { namedColors } from "../data/colors.js"; @@ -123,6 +124,12 @@ class SupportsRule { */ #properties = new Map(); + /** + * The selectors supported by this rule. + * @type {Set} + */ + #selectors = new Set(); + /** * Adds a property to the rule. * @param {string} property The name of the property. @@ -218,6 +225,24 @@ class SupportsRule { return supportedProperty.hasFunctions(); } + + /** + * Adds a selector to the rule. + * @param {string} selector The name of the selector. + * @returns {void} + */ + addSelector(selector) { + this.#selectors.add(selector); + } + + /** + * Determines if the rule supports a selector. + * @param {string} selector The name of the selector. + * @returns {boolean} `true` if the selector is supported, `false` if not. + */ + hasSelector(selector) { + return this.#selectors.has(selector); + } } /** @@ -303,6 +328,15 @@ class SupportsRules { hasPropertyFunctions(property) { return this.#rules.some(rule => rule.hasFunctions(property)); } + + /** + * Determines if any rule supports a selector. + * @param {string} selector The name of the selector. + * @returns {boolean} `true` if any rule supports the selector, `false` if not. + */ + hasSelector(selector) { + return this.#rules.some(rule => rule.hasSelector(selector)); + } } //----------------------------------------------------------------------------- @@ -347,6 +381,8 @@ export default { "Type '{{type}}' is not a {{availability}} available baseline feature.", notBaselineMediaCondition: "Media condition '{{condition}}' is not a {{availability}} available baseline feature.", + notBaselineSelector: + "Selector '{{selector}}' is not a {{availability}} available baseline feature.", }, }, @@ -459,6 +495,16 @@ export default { continue; } + + if ( + conditionChild.type === "FeatureFunction" && + conditionChild.feature === "selector" + ) { + for (const selectorChild of conditionChild.value + .children) { + supportsRule.addSelector(selectorChild.name); + } + } } }, @@ -625,6 +671,53 @@ export default { }); } }, + + Selector(node) { + for (const child of node.children) { + const selector = child.name; + + if (!selectors.has(selector)) { + continue; + } + + // if the selector has been tested in a @supports rule, don't check it + if (supportsRules.hasSelector(selector)) { + continue; + } + + const selectorLevel = selectors.get(selector); + + if (selectorLevel < baselineLevel) { + const loc = child.loc; + + // some selectors are prefixed with the : or :: symbols + let prefixSymbolLength = 0; + if (child.type === "PseudoClassSelector") { + prefixSymbolLength = 1; + } else if (child.type === "PseudoElementSelector") { + prefixSymbolLength = 2; + } + + context.report({ + loc: { + start: loc.start, + end: { + line: loc.start.line, + column: + loc.start.column + + selector.length + + prefixSymbolLength, + }, + }, + messageId: "notBaselineSelector", + data: { + selector, + availability, + }, + }); + } + } + }, }; }, }; diff --git a/tests/rules/require-baseline.test.js b/tests/rules/require-baseline.test.js index 2147367..b08358f 100644 --- a/tests/rules/require-baseline.test.js +++ b/tests/rules/require-baseline.test.js @@ -57,6 +57,9 @@ ruleTester.run("require-baseline", rule, { `@supports (width: abs(20% - 100px)) { a { width: abs(20% - 100px); } }`, + `@supports selector(:has()) { + h1:has(+ h2) { color: red; } + }`, "div { cursor: pointer; }", { code: `@property --foo { @@ -340,5 +343,59 @@ ruleTester.run("require-baseline", rule, { }, ], }, + { + code: "h1:has(+ h2) { margin: 0 0 0.25rem 0; }", + errors: [ + { + messageId: "notBaselineSelector", + data: { + selector: "has", + availability: "widely", + }, + line: 1, + column: 3, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: `@supports selector(:has()) {} + + @supports (color: red) { + h1:has(+ h2) { + color: red; + } + }`, + errors: [ + { + messageId: "notBaselineSelector", + data: { + selector: "has", + availability: "widely", + }, + line: 4, + column: 7, + endLine: 4, + endColumn: 11, + }, + ], + }, + { + code: "details::details-content { background-color: #a29bfe; }", + errors: [ + { + messageId: "notBaselineSelector", + data: { + selector: "details-content", + availability: "widely", + }, + line: 1, + column: 8, + endLine: 1, + endColumn: 25, + }, + ], + }, ], });