Repository: eslint/css
Pull request #63 (2025-02-22 13:43:25)

feat: add prefer-logical-properties rule
#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

#### What is the purpose of this pull request?

Add new rule.

#### What changes did you make? (Give an overview)

Added new rule for usage logical CSS properties instead of phisical.

#### Related Issues

#13

#### Is there anything you'd like reviewers to focus on?

I don't think so.


nzakas: Thanks for getting this started. There were some edge cases that were discussed in the original issue:

https://github.com/eslint/css/issues/13#issuecomment-2488917780
https://github.com/eslint/css/issues/13#issuecomment-2489258859

Can you describe how you're handling those?

Patch:
diff --git a/README.md b/README.md
index 9dbf6e3..ee76d8d 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,10 @@ pnpm install @eslint/css -D
 bun install @eslint/css -D
 ```
 
-For Deno:
+For Deno (experimental):
 
 ```shell
-deno add @eslint/css
+deno add jsr:@eslint/css
 ```
 
 ### Configurations
@@ -56,14 +56,15 @@ export default [
 
 <!-- Rule Table Start -->
 
-| **Rule Name**                                                    | **Description**                      | **Recommended** |
-| :--------------------------------------------------------------- | :----------------------------------- | :-------------: |
-| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md)   | Disallow duplicate @import rules     |       yes       |
-| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)             | Disallow empty blocks                |       yes       |
-| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md)     | Disallow invalid at-rules            |       yes       |
-| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties          |       yes       |
-| [`require-baseline`](./docs/rules/require-baseline.md)           | Enforce the use of baseline features |       yes       |
-| [`use-layers`](./docs/rules/use-layers.md)                       | Require use of layers                |       no        |
+| **Rule Name**                                                              | **Description**                       | **Recommended** |
+| :------------------------------------------------------------------------- | :------------------------------------ | :-------------: |
+| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md)             | Disallow duplicate @import rules      |       yes       |
+| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)                       | Disallow empty blocks                 |       yes       |
+| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md)               | Disallow invalid at-rules             |       yes       |
+| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md)           | Disallow invalid properties           |       yes       |
+| [`prefer-logical-properties`](./docs//rules//prefer-logical-properties.md) | Enforce the use of logical properties |       no        |
+| [`require-baseline`](./docs/rules/require-baseline.md)                     | Enforce the use of baseline features  |       yes       |
+| [`use-layers`](./docs/rules/use-layers.md)                                 | Require use of layers                 |       no        |
 
 <!-- Rule Table End -->
 
diff --git a/docs/rules/prefer-logical-properties.md b/docs/rules/prefer-logical-properties.md
new file mode 100644
index 0000000..a36d9b7
--- /dev/null
+++ b/docs/rules/prefer-logical-properties.md
@@ -0,0 +1,43 @@
+# prefer-logical-properties
+
+Prefer logical properties over physical properties.
+
+## Background
+
+Logical properties are a set of CSS properties that map to their physical counterparts. They are designed to make it easier to create styles that work in both left-to-right and right-to-left languages. Logical properties are useful for creating styles that are more flexible and easier to maintain.
+
+## Rule Details
+
+This rule checks for the use of physical properties and suggests using their logical counterparts instead.
+
+Examples of **incorrect** code for this rule:
+
+```css
+/* incorrect use of physical properties */
+a {
+	margin-left: 10px;
+}
+```
+
+Examples of **correct** code for this rule:
+
+```css
+a {
+	margin-inline-start: 10px;
+}
+```
+
+### Options
+
+This rule accepts an option object with the following properties:
+
+- `allowProperties` (default: `[]`) - Specify an array of physical properties that are allowed to be used.
+- `allowUnits` (default: `[]`) - Specify an array of physical units that are allowed to be used.
+
+## When Not to Use It
+
+If you aren't concerned with the use of logical properties, then you can safely disable this rule.
+
+## Prior Art
+
+- [stylelint-use-logical](https://github.com/csstools/stylelint-use-logical)
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/index.js b/src/index.js
index 98ed84d..7c9b686 100644
--- a/src/index.js
+++ b/src/index.js
@@ -13,6 +13,7 @@ import noEmptyBlocks from "./rules/no-empty-blocks.js";
 import noDuplicateImports from "./rules/no-duplicate-imports.js";
 import noInvalidProperties from "./rules/no-invalid-properties.js";
 import noInvalidAtRules from "./rules/no-invalid-at-rules.js";
+import preferLogicalProperties from "./rules/prefer-logical-properties.js";
 import useLayers from "./rules/use-layers.js";
 import requireBaseline from "./rules/require-baseline.js";
 
@@ -33,6 +34,7 @@ const plugin = {
 		"no-duplicate-imports": noDuplicateImports,
 		"no-invalid-at-rules": noInvalidAtRules,
 		"no-invalid-properties": noInvalidProperties,
+		"prefer-logical-properties": preferLogicalProperties,
 		"use-layers": useLayers,
 		"require-baseline": requireBaseline,
 	},
diff --git a/src/rules/prefer-logical-properties.js b/src/rules/prefer-logical-properties.js
new file mode 100644
index 0000000..e8bce54
--- /dev/null
+++ b/src/rules/prefer-logical-properties.js
@@ -0,0 +1,237 @@
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+const propertiesReplacements = new Map([
+	["bottom", "inset-block-end"],
+	["border-bottom", "border-block-end"],
+	["border-bottom-color", "border-block-end-color"],
+	["border-bottom-left-radius", "border-end-start-radius"],
+	["border-bottom-right-radius", "border-end-end-radius"],
+	["border-bottom-style", "border-block-end-style"],
+	["border-bottom-width", "border-block-end-width"],
+	["border-left", "border-inline-start"],
+	["border-left-color", "border-inline-start-color"],
+	["border-left-style", "border-inline-start-style"],
+	["border-left-width", "border-inline-start-width"],
+	["border-right", "border-inline-end"],
+	["border-right-color", "border-inline-end-color"],
+	["border-right-style", "border-inline-end-style"],
+	["border-right-width", "border-inline-end-width"],
+	["border-top", "border-block-start"],
+	["border-top-color", "border-block-start-color"],
+	["border-top-left-radius", "border-start-start-radius"],
+	["border-top-right-radius", "border-start-end-radius"],
+	["border-top-style", "border-block-start-style"],
+	["border-top-width", "border-block-start-width"],
+	["contain-intrinsic-height", "contain-intrinsic-block-size"],
+	["contain-intrinsic-width", "contain-intrinsic-inline-size"],
+	["height", "block-size"],
+	["left", "inset-inline-start"],
+	["margin-bottom", "margin-block-end"],
+	["margin-left", "margin-inline-start"],
+	["margin-right", "margin-inline-end"],
+	["margin-top", "margin-block-start"],
+	["max-height", "max-block-size"],
+	["max-width", "max-inline-size"],
+	["min-height", "min-block-size"],
+	["min-width", "min-inline-size"],
+	["overflow-x", "overflow-inline"],
+	["overflow-y", "overflow-block"],
+	["overscroll-behavior-x", "overscroll-behavior-inline"],
+	["overscroll-behavior-y", "overscroll-behavior-block"],
+	["padding-bottom", "padding-block-end"],
+	["padding-left", "padding-inline-start"],
+	["padding-right", "padding-inline-end"],
+	["padding-top", "padding-block-start"],
+	["right", "inset-inline-end"],
+	["scroll-margin-bottom", "scroll-margin-block-end"],
+	["scroll-margin-left", "scroll-margin-inline-start"],
+	["scroll-margin-right", "scroll-margin-inline-end"],
+	["scroll-margin-top", "scroll-margin-block-start"],
+	["scroll-padding-bottom", "scroll-padding-block-end"],
+	["scroll-padding-left", "scroll-padding-inline-start"],
+	["scroll-padding-right", "scroll-padding-inline-end"],
+	["scroll-padding-top", "scroll-padding-block-start"],
+	["top", "inset-block-start"],
+	["width", "inline-size"],
+]);
+
+const propertyValuesReplacements = new Map([
+	[
+		"text-align",
+		{
+			left: "start",
+			right: "end",
+		},
+	],
+	[
+		"resize",
+		{
+			horizontal: "inline",
+			vertical: "block",
+		},
+	],
+	[
+		"caption-side",
+		{
+			left: "inline-start",
+			right: "inline-end",
+		},
+	],
+	[
+		"box-orient",
+		{
+			horizontal: "inline-axis",
+			vertical: "block-axis",
+		},
+	],
+	[
+		"float",
+		{
+			left: "inline-start",
+			right: "inline-end",
+		},
+	],
+	[
+		"clear",
+		{
+			left: "inline-start",
+			right: "inline-end",
+		},
+	],
+]);
+
+const unitReplacements = new Map([
+	["cqh", "cqb"],
+	["cqw", "cqi"],
+	["dvh", "dvb"],
+	["dvw", "dvi"],
+	["lvh", "lvb"],
+	["lvw", "lvi"],
+	["svh", "svb"],
+	["svw", "svi"],
+	["vh", "vb"],
+	["vw", "vi"],
+]);
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+export default {
+	meta: {
+		type: /** @type {const} */ ("problem"),
+
+		fixable: /** @type {const} */ ("code"),
+
+		docs: {
+			description: "Enforce the use of logical properties",
+			url: "https://github.com/eslint/css/blob/main/docs/rules/prefer-logical-properties.md",
+		},
+
+		schema: [
+			{
+				type: "object",
+				properties: {
+					allowProperties: {
+						type: "array",
+						items: {
+							type: "string",
+						},
+					},
+					allowUnits: {
+						type: "array",
+						items: {
+							type: "string",
+						},
+					},
+				},
+				additionalProperties: false,
+			},
+		],
+
+		defaultOptions: [
+			{
+				allowProperties: [],
+				allowUnits: [],
+			},
+		],
+
+		messages: {
+			notLogicalProperty:
+				"Expected logical property '{{replacement}}' instead of '{{property}}'.",
+			notLogicalValue:
+				"Expected logical value '{{replacement}}' instead of '{{value}}'.",
+			notLogicalUnit:
+				"Expected logical unit '{{replacement}}' instead of '{{unit}}'.",
+		},
+	},
+
+	create(context) {
+		return {
+			Declaration(node) {
+				const parent = context.sourceCode.getParent(node);
+				if (parent.type === "SupportsDeclaration") {
+					return;
+				}
+
+				const allowProperties = context.options[0].allowProperties;
+				if (
+					propertiesReplacements.get(node.property) &&
+					!allowProperties.includes(node.property)
+				) {
+					context.report({
+						loc: node.loc,
+						messageId: "notLogicalProperty",
+						data: {
+							property: node.property,
+							replacement: propertiesReplacements.get(
+								node.property,
+							),
+						},
+					});
+				}
+
+				if (
+					propertyValuesReplacements.get(node.property) &&
+					node.value.children[0].type === "Identifier"
+				) {
+					const nodeValue = node.value.children[0].name;
+					if (
+						propertyValuesReplacements.get(node.property)[nodeValue]
+					) {
+						const replacement = propertyValuesReplacements.get(
+							node.property,
+						)[nodeValue];
+						if (replacement) {
+							context.report({
+								loc: node.value.children[0].loc,
+								messageId: "notLogicalValue",
+								data: {
+									value: nodeValue,
+									replacement,
+								},
+							});
+						}
+					}
+				}
+			},
+			Dimension(node) {
+				const allowUnits = context.options[0].allowUnits;
+				if (
+					unitReplacements.get(node.unit) &&
+					!allowUnits.includes(node.unit)
+				) {
+					context.report({
+						loc: node.loc,
+						messageId: "notLogicalUnit",
+						data: {
+							unit: node.unit,
+							replacement: unitReplacements.get(node.unit),
+						},
+					});
+				}
+			},
+		};
+	},
+};
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<string>}
+	 */
+	#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,
+							},
+						});
+					}
+				}
+			},
 		};
 	},
 };


Test patch:
diff --git a/tests/rules/prefer-logical-properties.test.js b/tests/rules/prefer-logical-properties.test.js
new file mode 100644
index 0000000..f5fb5cf
--- /dev/null
+++ b/tests/rules/prefer-logical-properties.test.js
@@ -0,0 +1,112 @@
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/prefer-logical-properties.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("prefer-logical-properties", rule, {
+	valid: [
+		"a { margin-block: 10px; }",
+		"a { padding-inline: 20px; }",
+		"a { margin: 10px; }",
+		"a { padding: 20px; }",
+		"a { text-align: start }",
+		"@supports (text-align: left) {}",
+		"@supports (padding-left: 10px) {}",
+		{
+			code: "a { padding-left: 20px; }",
+			options: [
+				{
+					allowProperties: ["padding-left"],
+				},
+			],
+		},
+		{
+			code: "a { inline-size: 100vw; }",
+			options: [
+				{
+					allowUnits: ["vw"],
+				},
+			],
+		},
+	],
+	invalid: [
+		{
+			code: "a { margin-top: 10px; }",
+			errors: [
+				{
+					messageId: "notLogicalProperty",
+					line: 1,
+					column: 5,
+					endLine: 1,
+					endColumn: 21,
+					data: {
+						property: "margin-top",
+						replacement: "margin-block-start",
+					},
+				},
+			],
+		},
+		{
+			code: "a { padding-top: 20px; }",
+			errors: [
+				{
+					messageId: "notLogicalProperty",
+					line: 1,
+					column: 5,
+					endLine: 1,
+					endColumn: 22,
+					data: {
+						property: "padding-top",
+						replacement: "padding-block-start",
+					},
+				},
+			],
+		},
+		{
+			code: "a { text-align: left }",
+			errors: [
+				{
+					messageId: "notLogicalValue",
+					line: 1,
+					column: 17,
+					endLine: 1,
+					endColumn: 21,
+					data: {
+						value: "left",
+						replacement: "start",
+					},
+				},
+			],
+		},
+		{
+			code: "a { block-size: 100vh }",
+			errors: [
+				{
+					messageId: "notLogicalUnit",
+					line: 1,
+					column: 17,
+					endLine: 1,
+					endColumn: 22,
+					data: {
+						unit: "vh",
+						replacement: "vb",
+					},
+				},
+			],
+		},
+	],
+});
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,
+				},
+			],
+		},
 	],
 });