Skip to content

Commit

Permalink
Add partial support for Media Queries Level 4 (#1749)
Browse files Browse the repository at this point in the history
  • Loading branch information
nex3 committed Jul 22, 2022
1 parent 0d4c0d0 commit eeedebc
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 123 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Expand Up @@ -8,6 +8,14 @@

See https://sass-lang.com/d/bogus-combinators for more details.

* Add partial support for new media query syntax from Media Queries Level 4. The
only exception are logical operations nested within parentheses, as these were
previously interpreted differently as SassScript expressions.

A parenthesized media condition that begins with `not` or an opening
parenthesis now produces a deprecation warning. In a future release, these
will be interpreted as plain CSS instead.

* Deprecate passing non-`deg` units to `color.hwb()`'s `$hue` argument.

### Dart API
Expand Down
3 changes: 2 additions & 1 deletion lib/sass.dart
Expand Up @@ -28,7 +28,8 @@ export 'src/exception.dart' show SassException;
export 'src/importer.dart';
export 'src/logger.dart';
export 'src/syntax.dart';
export 'src/value.dart' hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat;
export 'src/value.dart'
hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat;
export 'src/visitor/serialize.dart' show OutputStyle;
export 'src/evaluation_context.dart' show warn;

Expand Down
112 changes: 71 additions & 41 deletions lib/src/ast/css/media_query.dart
Expand Up @@ -15,14 +15,24 @@ class CssMediaQuery {

/// The media type, for example "screen" or "print".
///
/// This may be `null`. If so, [features] will not be empty.
/// This may be `null`. If so, [conditions] will not be empty.
final String? type;

/// Feature queries, including parentheses.
final List<String> features;
/// Whether [conditions] is a conjunction or a disjunction.
///
/// In other words, if this is `true this query matches when _all_
/// [conditions] are met, and if it's `false` this query matches when _any_
/// condition in [conditions] is met.
///
/// If this is [false], [modifier] and [type] will both be `null`.
final bool conjunction;

/// Whether this media query only specifies features.
bool get isCondition => modifier == null && type == null;
/// Media conditions, including parentheses.
///
/// This is anything that can appear in the [`<media-in-parens>`] production.
///
/// [`<media-in-parens>`]: https://drafts.csswg.org/mediaqueries-4/#typedef-media-in-parens
final List<String> conditions;

/// Whether this media query matches all media types.
bool get matchesAllTypes => type == null || equalsIgnoreCase(type, 'all');
Expand All @@ -36,47 +46,67 @@ class CssMediaQuery {
{Object? url, Logger? logger}) =>
MediaQueryParser(contents, url: url, logger: logger).parse();

/// Creates a media query specifies a type and, optionally, features.
CssMediaQuery(this.type, {this.modifier, Iterable<String>? features})
: features = features == null ? const [] : List.unmodifiable(features);

/// Creates a media query that only specifies features.
CssMediaQuery.condition(Iterable<String> features)
/// Creates a media query specifies a type and, optionally, conditions.
///
/// This always sets [conjunction] to `true`.
CssMediaQuery.type(this.type, {this.modifier, Iterable<String>? conditions})
: conjunction = true,
conditions =
conditions == null ? const [] : List.unmodifiable(conditions);

/// Creates a media query that matches [conditions] according to
/// [conjunction].
///
/// The [conjunction] argument may not be null if [conditions] is longer than
/// a single element.
CssMediaQuery.condition(Iterable<String> conditions, {bool? conjunction})
: modifier = null,
type = null,
features = List.unmodifiable(features);
conjunction = conjunction ?? true,
conditions = List.unmodifiable(conditions) {
if (this.conditions.length > 1 && conjunction == null) {
throw ArgumentError(
"If conditions is longer than one element, conjunction may not be "
"null.");
}
}

/// Merges this with [other] to return a query that matches the intersection
/// of both inputs.
MediaQueryMergeResult merge(CssMediaQuery other) {
if (!conjunction || !other.conjunction) {
return MediaQueryMergeResult.unrepresentable;
}

var ourModifier = this.modifier?.toLowerCase();
var ourType = this.type?.toLowerCase();
var theirModifier = other.modifier?.toLowerCase();
var theirType = other.type?.toLowerCase();

if (ourType == null && theirType == null) {
return MediaQuerySuccessfulMergeResult._(
CssMediaQuery.condition([...this.features, ...other.features]));
return MediaQuerySuccessfulMergeResult._(CssMediaQuery.condition(
[...this.conditions, ...other.conditions],
conjunction: true));
}

String? modifier;
String? type;
List<String> features;
List<String> conditions;
if ((ourModifier == 'not') != (theirModifier == 'not')) {
if (ourType == theirType) {
var negativeFeatures =
ourModifier == 'not' ? this.features : other.features;
var positiveFeatures =
ourModifier == 'not' ? other.features : this.features;
var negativeConditions =
ourModifier == 'not' ? this.conditions : other.conditions;
var positiveConditions =
ourModifier == 'not' ? other.conditions : this.conditions;

// If the negative features are a subset of the positive features, the
// If the negative conditions are a subset of the positive conditions, the
// query is empty. For example, `not screen and (color)` has no
// intersection with `screen and (color) and (grid)`.
//
// However, `not screen and (color)` *does* intersect with `screen and
// (grid)`, because it means `not (screen and (color))` and so it allows
// a screen with no color but with a grid.
if (negativeFeatures.every(positiveFeatures.contains)) {
if (negativeConditions.every(positiveConditions.contains)) {
return MediaQueryMergeResult.empty;
} else {
return MediaQueryMergeResult.unrepresentable;
Expand All @@ -88,30 +118,30 @@ class CssMediaQuery {
if (ourModifier == 'not') {
modifier = theirModifier;
type = theirType;
features = other.features;
conditions = other.conditions;
} else {
modifier = ourModifier;
type = ourType;
features = this.features;
conditions = this.conditions;
}
} else if (ourModifier == 'not') {
assert(theirModifier == 'not');
// CSS has no way of representing "neither screen nor print".
if (ourType != theirType) return MediaQueryMergeResult.unrepresentable;

var moreFeatures = this.features.length > other.features.length
? this.features
: other.features;
var fewerFeatures = this.features.length > other.features.length
? other.features
: this.features;
var moreConditions = this.conditions.length > other.conditions.length
? this.conditions
: other.conditions;
var fewerConditions = this.conditions.length > other.conditions.length
? other.conditions
: this.conditions;

// If one set of features is a superset of the other, use those features
// If one set of conditions is a superset of the other, use those conditions
// because they're strictly narrower.
if (fewerFeatures.every(moreFeatures.contains)) {
if (fewerConditions.every(moreConditions.contains)) {
modifier = ourModifier; // "not"
type = ourType;
features = moreFeatures;
conditions = moreConditions;
} else {
// Otherwise, there's no way to represent the intersection.
return MediaQueryMergeResult.unrepresentable;
Expand All @@ -121,41 +151,41 @@ class CssMediaQuery {
// Omit the type if either input query did, since that indicates that they
// aren't targeting a browser that requires "all and".
type = (other.matchesAllTypes && ourType == null) ? null : theirType;
features = [...this.features, ...other.features];
conditions = [...this.conditions, ...other.conditions];
} else if (other.matchesAllTypes) {
modifier = ourModifier;
type = ourType;
features = [...this.features, ...other.features];
conditions = [...this.conditions, ...other.conditions];
} else if (ourType != theirType) {
return MediaQueryMergeResult.empty;
} else {
modifier = ourModifier ?? theirModifier;
type = ourType;
features = [...this.features, ...other.features];
conditions = [...this.conditions, ...other.conditions];
}

return MediaQuerySuccessfulMergeResult._(CssMediaQuery(
return MediaQuerySuccessfulMergeResult._(CssMediaQuery.type(
type == ourType ? this.type : other.type,
modifier: modifier == ourModifier ? this.modifier : other.modifier,
features: features));
conditions: conditions));
}

bool operator ==(Object other) =>
other is CssMediaQuery &&
other.modifier == modifier &&
other.type == type &&
listEquals(other.features, features);
listEquals(other.conditions, conditions);

int get hashCode => modifier.hashCode ^ type.hashCode ^ listHash(features);
int get hashCode => modifier.hashCode ^ type.hashCode ^ listHash(conditions);

String toString() {
var buffer = StringBuffer();
if (modifier != null) buffer.write("$modifier ");
if (type != null) {
buffer.write(type);
if (features.isNotEmpty) buffer.write(" and ");
if (conditions.isNotEmpty) buffer.write(" and ");
}
buffer.write(features.join(" and "));
buffer.write(conditions.join(conjunction ? " and " : " or "));
return buffer.toString();
}
}
Expand Down
106 changes: 74 additions & 32 deletions lib/src/parse/media_query.dart
Expand Up @@ -20,6 +20,7 @@ class MediaQueryParser extends Parser {
do {
whitespace();
queries.add(_mediaQuery());
whitespace();
} while (scanner.scanChar($comma));
scanner.expectDone();
return queries;
Expand All @@ -29,52 +30,93 @@ class MediaQueryParser extends Parser {
/// Consumes a single media query.
CssMediaQuery _mediaQuery() {
// This is somewhat duplicated in StylesheetParser._mediaQuery.
if (scanner.peekChar() == $lparen) {
var conditions = [_mediaInParens()];
whitespace();

var conjunction = true;
if (scanIdentifier("and")) {
expectWhitespace();
conditions.addAll(_mediaLogicSequence("and"));
} else if (scanIdentifier("or")) {
expectWhitespace();
conjunction = false;
conditions.addAll(_mediaLogicSequence("or"));
}

return CssMediaQuery.condition(conditions, conjunction: conjunction);
}

String? modifier;
String? type;
if (scanner.peekChar() != $lparen) {
var identifier1 = identifier();
whitespace();
var identifier1 = identifier();

if (equalsIgnoreCase(identifier1, "not")) {
expectWhitespace();
if (!lookingAtIdentifier()) {
// For example, "@media screen {"
return CssMediaQuery(identifier1);
// For example, "@media not (...) {"
return CssMediaQuery.condition(["(not ${_mediaInParens()})"]);
}
}

var identifier2 = identifier();
whitespace();
whitespace();
if (!lookingAtIdentifier()) {
// For example, "@media screen {"
return CssMediaQuery.type(identifier1);
}

if (equalsIgnoreCase(identifier2, "and")) {
// For example, "@media screen and ..."
type = identifier1;
var identifier2 = identifier();

if (equalsIgnoreCase(identifier2, "and")) {
expectWhitespace();
// For example, "@media screen and ..."
type = identifier1;
} else {
whitespace();
modifier = identifier1;
type = identifier2;
if (scanIdentifier("and")) {
// For example, "@media only screen and ..."
expectWhitespace();
} else {
modifier = identifier1;
type = identifier2;
if (scanIdentifier("and")) {
// For example, "@media only screen and ..."
whitespace();
} else {
// For example, "@media only screen {"
return CssMediaQuery(type, modifier: modifier);
}
// For example, "@media only screen {"
return CssMediaQuery.type(type, modifier: modifier);
}
}

// We've consumed either `IDENTIFIER "and"`, `IDENTIFIER IDENTIFIER "and"`,
// or no text.
// We've consumed either `IDENTIFIER "and"` or
// `IDENTIFIER IDENTIFIER "and"`.

var features = <String>[];
do {
whitespace();
scanner.expectChar($lparen);
features.add("(${declarationValue()})");
scanner.expectChar($rparen);
if (scanIdentifier("not")) {
// For example, "@media screen and not (...) {"
expectWhitespace();
return CssMediaQuery.type(type,
modifier: modifier, conditions: ["(not ${_mediaInParens()})"]);
}

return CssMediaQuery.type(type,
modifier: modifier, conditions: _mediaLogicSequence("and"));
}

/// Consumes one or more `<media-in-parens>` expressions separated by
/// [operator] and returns them.
List<String> _mediaLogicSequence(String operator) {
var result = <String>[];
while (true) {
result.add(_mediaInParens());
whitespace();
} while (scanIdentifier("and"));

if (type == null) {
return CssMediaQuery.condition(features);
} else {
return CssMediaQuery(type, modifier: modifier, features: features);
if (!scanIdentifier(operator)) return result;
expectWhitespace();
}
}

/// Consumes a `<media-in-parens>` expression and returns it, parentheses
/// included.
String _mediaInParens() {
scanner.expectChar($lparen, name: "media condition in parentheses");
var result = "(${declarationValue()})";
scanner.expectChar($rparen);
return result;
}
}

0 comments on commit eeedebc

Please sign in to comment.