diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md index 374287f169..3d30827be2 100644 --- a/docs/user-guide/rules.md +++ b/docs/user-guide/rules.md @@ -217,6 +217,7 @@ Enforce one representation of things that have multiple with these `notation` (s - [`hue-degree-notation`](../../lib/rules/hue-degree-notation/README.md): Specify number or angle notation for degree hues (Autofixable). - [`import-notation`](../../lib/rules/import-notation/README.md): Specify string or URL notation for `@import` rules (Autofixable). - [`keyframe-selector-notation`](../../lib/rules/keyframe-selector-notation/README.md): Specify keyword or percentage notation for keyframe selectors (Autofixable). +- [`media-feature-range-notation`](../../lib/rules/media-feature-range-notation/README.md): Specify context or prefix notation for media feature ranges. - [`selector-not-notation`](../../lib/rules/selector-not-notation/README.md): Specify simple or complex notation for `:not()` pseudo-class selectors (Autofixable). - [`selector-pseudo-element-colon-notation`](../../lib/rules/selector-pseudo-element-colon-notation/README.md): Specify single or double colon notation for applicable pseudo-element selectors (Autofixable). diff --git a/lib/reference/mediaFeatures.js b/lib/reference/mediaFeatures.js index 5362da3f97..44749aaa48 100644 --- a/lib/reference/mediaFeatures.js +++ b/lib/reference/mediaFeatures.js @@ -14,18 +14,24 @@ const deprecatedMediaFeatureNames = new Set([ 'min-device-width', ]); -const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, [ - 'any-hover', - 'any-pointer', +const rangeTypeMediaFeatureNames = new Set([ 'aspect-ratio', + 'color-index', 'color', + 'height', + 'monochrome', + 'resolution', + 'width', +]); + +const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, rangeTypeMediaFeatureNames, [ + 'any-hover', + 'any-pointer', 'color-gamut', - 'color-index', 'display-mode', 'dynamic-range', 'forced-colors', 'grid', - 'height', 'hover', 'inverted-colors', 'light-level', @@ -43,7 +49,6 @@ const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, [ 'min-monochrome', 'min-resolution', 'min-width', - 'monochrome', 'orientation', 'overflow-block', 'overflow-inline', @@ -52,14 +57,13 @@ const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, [ 'prefers-contrast', 'prefers-reduced-motion', 'prefers-reduced-transparency', - 'resolution', 'scan', 'scripting', 'update', 'video-dynamic-range', - 'width', ]); module.exports = { + rangeTypeMediaFeatureNames, mediaFeatureNames, }; diff --git a/lib/rules/index.js b/lib/rules/index.js index 009a3918f7..df7cefaf70 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -197,6 +197,7 @@ const rules = { 'media-feature-parentheses-space-inside': importLazy(() => require('./media-feature-parentheses-space-inside'), )(), + 'media-feature-range-notation': importLazy(() => require('./media-feature-range-notation'))(), 'media-feature-range-operator-space-after': importLazy(() => require('./media-feature-range-operator-space-after'), )(), diff --git a/lib/rules/media-feature-range-notation/README.md b/lib/rules/media-feature-range-notation/README.md new file mode 100644 index 0000000000..0a413552d2 --- /dev/null +++ b/lib/rules/media-feature-range-notation/README.md @@ -0,0 +1,74 @@ +# media-feature-range-notation + +Specify context or prefix notation for media feature ranges. + + +```css +@media (width >= 600px) and (min-width: 600px) {} +/** ↑ ↑ + * These media feature notations */ +``` + +Media features of the range type can be written using prefixes or the more modern context notation. + +Because `min-` and `max-` both equate to range comparisons that include the value, they may be [limiting in certain situations](https://drafts.csswg.org/mediaqueries/#mq-min-max). + +## Options + +`string`: `"context"|"prefix"` + +### `"context"` + +Media feature ranges _must always_ use context notation. + +The following patterns are considered problems: + + +```css +@media (min-width: 1px) {} +``` + + +```css +@media (min-width: 1px) and (max-width: 2px) {} +``` + +The following patterns are _not_ considered problems: + + +```css +@media (width >= 1px) {} +``` + + +```css +@media (1px <= width >= 2px) {} +``` + +### `"prefix"` + +Media feature ranges _must always_ use prefix notation. + +The following patterns are considered problems: + + +```css +@media (width >= 1px) {} +``` + + +```css +@media (1px <= width >= 2px) {} +``` + +The following patterns are _not_ considered problems: + + +```css +@media (min-width: 1px) {} +``` + + +```css +@media (min-width: 1px) and (max-width: 2px) {} +``` diff --git a/lib/rules/media-feature-range-notation/__tests__/index.js b/lib/rules/media-feature-range-notation/__tests__/index.js new file mode 100644 index 0000000000..92354ae4da --- /dev/null +++ b/lib/rules/media-feature-range-notation/__tests__/index.js @@ -0,0 +1,177 @@ +'use strict'; + +const stripIndent = require('common-tags').stripIndent; + +const { messages, ruleName } = require('..'); + +testRule({ + ruleName, + config: ['context'], + + accept: [ + { + code: '@media {}', + description: 'empty media query', + }, + { + code: '@media () {}', + description: 'empty media feature', + }, + { + code: '@media screen {}', + description: 'keyword', + }, + { + code: '@media (color) {}', + description: 'range type media feature in boolean context', + }, + { + code: '@media (color > 0) {}', + description: 'range type media feature in non-boolean context', + }, + { + code: '@media (pointer: fine) {}', + description: 'discrete type media feature', + }, + { + code: '@media (width >= 1px) {}', + description: 'range type media feature in context notation', + }, + { + code: '@media screen and (width >= 1px) {}', + description: 'range type media feature in context notation with keyword', + }, + { + code: '@media not print, (width >= 1px) {}', + description: 'range type media feature in context notation in media query list', + }, + { + code: '@media (1px <= width >= 2px) {}', + description: 'range type media feature in context notation with two values', + }, + ], + + reject: [ + { + code: '@media (min-width: 1px) {}', + description: 'range type media feature in prefix notation', + message: messages.expected('context'), + line: 1, + column: 8, + endLine: 1, + endColumn: 24, + }, + { + code: '@media screen and (min-width: 1px) {}', + description: 'range type media feature in prefix notation with keyword', + message: messages.expected('context'), + line: 1, + column: 19, + endLine: 1, + endColumn: 35, + }, + { + code: '@media not print, (min-width: 1px) {}', + description: 'range type media feature in prefix notation in media query list', + message: messages.expected('context'), + line: 1, + column: 19, + endLine: 1, + endColumn: 35, + }, + { + code: stripIndent` + @media (min-width: 1px) + and (max-width: 2px) {} + `, + description: 'two range type media features in prefix notation', + warnings: [ + { message: messages.expected('context'), line: 1, column: 8, endLine: 1, endColumn: 24 }, + { message: messages.expected('context'), line: 2, column: 7, endLine: 2, endColumn: 23 }, + ], + }, + ], +}); + +testRule({ + ruleName, + config: ['prefix'], + + accept: [ + { + code: '@media {}', + description: 'empty media query', + }, + { + code: '@media () {}', + description: 'empty media feature', + }, + { + code: '@media screen {}', + description: 'keyword', + }, + { + code: '@media (color) {}', + description: 'range type media feature in boolean context', + }, + { + code: '@media (min-color: 1) {}', + description: 'range type media feature in non-boolean context', + }, + { + code: '@media (pointer: fine) {}', + description: 'discrete type media query', + }, + { + code: '@media (min-width: 1px) {}', + description: 'range type media feature in prefix notation', + }, + { + code: '@media screen and (min-width: 1px) {}', + description: 'range type media feature in prefix notation with keyword', + }, + { + code: '@media not print, (min-width: 1px) {}', + description: 'range type media feature in prefix notation in media query list', + }, + ], + + reject: [ + { + code: '@media (width >= 1px) {}', + description: 'range type media feature in context notation', + message: messages.expected('prefix'), + line: 1, + column: 8, + endLine: 1, + endColumn: 22, + }, + { + code: '@media screen and (width >= 1px) {}', + description: 'range type media feature in context notation with keyword', + message: messages.expected('prefix'), + line: 1, + column: 19, + endLine: 1, + endColumn: 33, + }, + { + code: '@media not print, (width >= 1px) {}', + description: 'range type media feature in context notation in media query list', + message: messages.expected('prefix'), + line: 1, + column: 19, + endLine: 1, + endColumn: 33, + }, + { + code: '@media (1px < width >= 2px) {}', + description: 'range type media feature in context notation with two values', + message: messages.expected('prefix'), + line: 1, + column: 8, + endLine: 1, + endColumn: 28, + }, + ], +}); diff --git a/lib/rules/media-feature-range-notation/index.js b/lib/rules/media-feature-range-notation/index.js new file mode 100644 index 0000000000..2eb233000d --- /dev/null +++ b/lib/rules/media-feature-range-notation/index.js @@ -0,0 +1,89 @@ +'use strict'; + +const mediaParser = require('postcss-media-query-parser').default; + +const { rangeTypeMediaFeatureNames } = require('../../reference/mediaFeatures.js'); +const atRuleParamIndex = require('../../utils/atRuleParamIndex'); +const isRangeContextMediaFeature = require('../../utils/isRangeContextMediaFeature'); +const isStandardSyntaxMediaFeatureName = require('../../utils/isStandardSyntaxMediaFeatureName'); +const report = require('../../utils/report'); +const ruleMessages = require('../../utils/ruleMessages'); +const validateOptions = require('../../utils/validateOptions'); + +const ruleName = 'media-feature-range-notation'; + +const messages = ruleMessages(ruleName, { + expected: (primary) => `Expected "${primary}" media feature range notation`, +}); + +const meta = { + url: 'https://stylelint.io/user-guide/rules/media-feature-range-notation', +}; + +/** @type {import('stylelint').Rule} */ +const rule = (primary) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ['prefix', 'context'], + }); + + if (!validOptions) { + return; + } + + root.walkAtRules(/^media$/i, (atRule) => { + mediaParser(atRule.params).walk(/^media-feature$/i, ({ parent, value }) => { + if (!isStandardSyntaxMediaFeatureName(value)) return; + + if (!isRangeContextMediaFeature(value) && isInBooleanContext(parent)) return; + + if (!isRangeContextMediaFeature(value) && !isRangeTypeMediaFeature(value)) return; + + if (primary === 'prefix' && isPrefixedRangeMediaFeature(value)) return; + + if (primary === 'context' && isRangeContextMediaFeature(value)) return; + + const index = atRuleParamIndex(atRule) + parent.sourceIndex; + const endIndex = index + parent.value.length; + + report({ + message: messages.expected(primary), + node: atRule, + index, + endIndex, + result, + ruleName, + }); + }); + }); + }; +}; + +/** + * @param {string} mediaFeature + */ +function isPrefixedRangeMediaFeature(mediaFeature) { + return mediaFeature.startsWith('min-') || mediaFeature.startsWith('max-'); +} + +/** + * @param {string} mediaFeature + */ +function isRangeTypeMediaFeature(mediaFeature) { + const unprefixedMediaFeature = mediaFeature.replace(/^(?:min|max)-/, ''); + + return rangeTypeMediaFeatureNames.has(unprefixedMediaFeature); +} + +/** + * @param {import('postcss-media-query-parser').Node} mediaFeatureExpressionNode + */ +function isInBooleanContext({ nodes }) { + return nodes && nodes.length === 1; +} + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule;