Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add partial support for Media Queries Level 4 #1749

Merged
merged 4 commits into from Jul 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.

### JS API
Expand Down
3 changes: 2 additions & 1 deletion lib/sass.dart
Expand Up @@ -26,7 +26,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_
nex3 marked this conversation as resolved.
Show resolved Hide resolved
/// [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`.
nex3 marked this conversation as resolved.
Show resolved Hide resolved
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.");
Comment on lines +69 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: would adding single-quotes around conditions and conjunction make it more readable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not the typical style for ArgumentErrors, I think, and hewing to a common style is probably more valuable overall.

}
}

/// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conjunction should be taken into account by equality checks.


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")) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, it took me a while to review because I was confused about this part, I was reading the syntax for <media-query> and I didn't realize it had two lines 😅 (the second showing [ not | only ]? <media-type> [ and <media-condition-without-or> ]?)

I didn't realize one could have and like that but not or :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it has to balance remaining compatible with the legacy syntax even in places where it's confusing with supporting the new syntax.

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;
}
}