Module: Dommy::Internal::CSS::MediaQuery
- Defined in:
- lib/dommy/internal/css/media_query.rb
Overview
A Media Queries Level 4 subset evaluator for matchMedia and
friends. Queries are matched against an Environment snapshot
rather than a real device.
Supported: media types (all/screen true, anything else false),
not / only prefixes, and chains, comma-separated OR lists,
not before a single condition, (feature: value) and
(feature) boolean contexts, and the range syntax for
width/height ((width >= 600px), (400px <= width <= 800px)).
Deliberately given up: the or combinator, nested parentheses
((not (width)), general <media-condition> grouping), calc(),
and most discrete features beyond the ones listed in
FEATURE_VALUES below. Any query we cannot parse — including one
using or — evaluates to false, mirroring the spec's "unknown
query becomes not all" rule; we never raise.
Defined Under Namespace
Classes: Environment
Constant Summary collapse
- DEFAULT =
Environment.default.freeze
- FEATURE_VALUES =
Valid values for the discrete features we support; anything else is treated as unparseable (query becomes false).
{ "prefers-color-scheme" => %w[light dark], "prefers-reduced-motion" => %w[no-preference reduce], "hover" => %w[none hover], "any-hover" => %w[none hover], "pointer" => %w[none coarse fine], "any-pointer" => %w[none coarse fine] }.freeze
Class Method Summary collapse
- .compare(left, op, right) ⇒ Object
-
.compare_with_prefix(name, actual, target) ⇒ Object
min- prefix means "at least", max- means "at most", bare name means exact equality.
-
.evaluate_aspect_ratio(name, value, env) ⇒ Object
(aspect-ratio: a/b)— compare width/height against a/b without floating point: width * b <=> a * height. - .evaluate_boolean_feature(name, env) ⇒ Object
-
.evaluate_condition(inner, env) ⇒ Object
inneris the lower-cased text between parentheses. - .evaluate_feature(name, value, env) ⇒ Object
-
.evaluate_range(inner, env) ⇒ Object
Range syntax, single (
width >= 600px,600px <= width) or double (400px <= width <= 800px). -
.match?(text, environment = DEFAULT) ⇒ Boolean
Evaluates a media query list.
-
.parse_length(value) ⇒ Object
Lengths in px; em/rem are converted at the canonical 16px.
-
.parse_resolution(value) ⇒ Object
Resolutions normalized to dppx; dpi divides by 96.
-
.query_match?(query, env) ⇒ Boolean
--- single query ---------------------------------------------.
- .range_feature_value(name, env) ⇒ Object
-
.tokenize(query) ⇒ Object
Splits a query into lower-cased keyword strings and
[:cond, inner]pairs for parenthesized conditions.
Class Method Details
.compare(left, op, right) ⇒ Object
254 255 256 257 258 259 260 261 262 |
# File 'lib/dommy/internal/css/media_query.rb', line 254 def compare(left, op, right) case op when "<" then left < right when "<=" then left <= right when ">" then left > right when ">=" then left >= right when "=" then left == right end end |
.compare_with_prefix(name, actual, target) ⇒ Object
min- prefix means "at least", max- means "at most", bare name means exact equality.
266 267 268 269 270 271 272 273 274 |
# File 'lib/dommy/internal/css/media_query.rb', line 266 def compare_with_prefix(name, actual, target) if name.start_with?("min-") actual >= target elsif name.start_with?("max-") actual <= target else actual == target end end |
.evaluate_aspect_ratio(name, value, env) ⇒ Object
(aspect-ratio: a/b) — compare width/height against a/b
without floating point: width * b <=> a * height.
207 208 209 210 211 212 213 214 215 216 |
# File 'lib/dommy/internal/css/media_query.rb', line 207 def evaluate_aspect_ratio(name, value, env) match = value.match(%r{\A(\d+)\s*/\s*(\d+)\z}) return nil unless match a = Integer(match[1], 10) b = Integer(match[2], 10) return nil if a.zero? || b.zero? compare_with_prefix(name, env. * b, a * env.) end |
.evaluate_boolean_feature(name, env) ⇒ Object
196 197 198 199 200 201 202 203 |
# File 'lib/dommy/internal/css/media_query.rb', line 196 def evaluate_boolean_feature(name, env) case name when "width" then env. > 0 when "height" then env. > 0 when "hover", "any-hover" then env.hover != "none" when "pointer", "any-pointer" then env.pointer != "none" end end |
.evaluate_condition(inner, env) ⇒ Object
inner is the lower-cased text between parentheses. Returns
true/false, or nil when the condition cannot be interpreted.
154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/dommy/internal/css/media_query.rb', line 154 def evaluate_condition(inner, env) return nil if inner.empty? if inner.match?(/[<>]/) || (inner.include?("=") && !inner.include?(":")) evaluate_range(inner, env) elsif inner.include?(":") name, _, value = inner.partition(":") evaluate_feature(name.strip, value.strip, env) else evaluate_boolean_feature(inner, env) end end |
.evaluate_feature(name, value, env) ⇒ Object
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/dommy/internal/css/media_query.rb', line 167 def evaluate_feature(name, value, env) case name when "width", "min-width", "max-width" px = parse_length(value) px && compare_with_prefix(name, env., px) when "height", "min-height", "max-height" px = parse_length(value) px && compare_with_prefix(name, env., px) when "orientation" case value when "portrait" then env. >= env. when "landscape" then env. > env. end when "aspect-ratio", "min-aspect-ratio", "max-aspect-ratio" evaluate_aspect_ratio(name, value, env) when "prefers-color-scheme" FEATURE_VALUES[name].include?(value) ? env.prefers_color_scheme == value : nil when "prefers-reduced-motion" FEATURE_VALUES[name].include?(value) ? env.prefers_reduced_motion == value : nil when "hover", "any-hover" FEATURE_VALUES[name].include?(value) ? env.hover == value : nil when "pointer", "any-pointer" FEATURE_VALUES[name].include?(value) ? env.pointer == value : nil when "resolution", "min-resolution", "max-resolution" dppx = parse_resolution(value) dppx && compare_with_prefix(name, env.device_pixel_ratio, dppx) end end |
.evaluate_range(inner, env) ⇒ Object
Range syntax, single (width >= 600px, 600px <= width) or
double (400px <= width <= 800px). width/height only.
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/dommy/internal/css/media_query.rb', line 220 def evaluate_range(inner, env) parts = inner.split(/(<=|>=|<|>|=)/).map(&:strip) case parts.size when 3 left, op, right = parts if (actual = range_feature_value(left, env)) value = parse_length(right) value && compare(actual, op, value) elsif (actual = range_feature_value(right, env)) value = parse_length(left) value && compare(value, op, actual) end when 5 low, op1, name, op2, high = parts actual = range_feature_value(name, env) return nil unless actual return nil unless (%w[< <=].include?(op1) && %w[< <=].include?(op2)) || (%w[> >=].include?(op1) && %w[> >=].include?(op2)) low_px = parse_length(low) high_px = parse_length(high) return nil unless low_px && high_px compare(low_px, op1, actual) && compare(actual, op2, high_px) end end |
.match?(text, environment = DEFAULT) ⇒ Boolean
Evaluates a media query list. Comma-separated queries OR
together: the list matches if any single query matches. The
empty string is the empty query list, which matches (this is
what matchMedia("") does in browsers).
54 55 56 57 58 59 60 61 |
# File 'lib/dommy/internal/css/media_query.rb', line 54 def match?(text, environment = DEFAULT) text = text.to_s.strip return true if text.empty? # Commas cannot appear inside our supported conditions, so a # plain split is safe. text.split(",", -1).any? { |query| query_match?(query.strip, environment) } end |
.parse_length(value) ⇒ Object
Lengths in px; em/rem are converted at the canonical 16px. A bare 0 (no unit) is a valid length; any other unitless number is not.
281 282 283 284 285 286 287 288 289 |
# File 'lib/dommy/internal/css/media_query.rb', line 281 def parse_length(value) return 0.0 if value.match?(/\A0+(?:\.0+)?\z/) match = value.match(/\A(\d+(?:\.\d+)?)(px|em|rem)\z/) return nil unless match number = Float(match[1]) match[2] == "px" ? number : number * 16 end |
.parse_resolution(value) ⇒ Object
Resolutions normalized to dppx; dpi divides by 96.
292 293 294 295 296 297 298 |
# File 'lib/dommy/internal/css/media_query.rb', line 292 def parse_resolution(value) match = value.match(/\A(\d+(?:\.\d+)?)(x|dppx|dpi)\z/) return nil unless match number = Float(match[1]) match[2] == "dpi" ? number / 96 : number end |
.query_match?(query, env) ⇒ Boolean
--- single query ---------------------------------------------
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/dommy/internal/css/media_query.rb', line 65 def query_match?(query, env) tokens = tokenize(query) return false if tokens.nil? || tokens.empty? i = 0 negate_query = false if tokens[0] == "not" negate_query = true i = 1 elsif tokens[0] == "only" # `only` is only valid immediately before a media type # (`only screen`); `only (condition)` is a parse error. return false unless tokens[1].is_a?(String) i = 1 end return false if i >= tokens.size # First segment: a media type word, or a leading condition. first = tokens[i] if first.is_a?(String) return false if %w[and not only or].include?(first) result = first == "all" || first == "screen" i += 1 else result = evaluate_condition(first[1], env) return false if result.nil? i += 1 end # Remaining segments: `and [not] ( ... )` repeated. Anything # else (notably `or`) makes the whole query unparseable. while i < tokens.size return false unless tokens[i] == "and" i += 1 negate_condition = false if tokens[i] == "not" negate_condition = true i += 1 end token = tokens[i] return false unless token.is_a?(Array) value = evaluate_condition(token[1], env) return false if value.nil? value = !value if negate_condition result &&= value i += 1 end negate_query ? !result : result end |
.range_feature_value(name, env) ⇒ Object
247 248 249 250 251 252 |
# File 'lib/dommy/internal/css/media_query.rb', line 247 def range_feature_value(name, env) case name when "width" then env. when "height" then env. end end |
.tokenize(query) ⇒ Object
Splits a query into lower-cased keyword strings and
[:cond, inner] pairs for parenthesized conditions. Returns
nil when the query contains anything else (stray characters,
nested or unbalanced parentheses).
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/dommy/internal/css/media_query.rb', line 126 def tokenize(query) tokens = [] rest = query until (rest = rest.lstrip).empty? if rest.start_with?("(") close = rest.index(")") return nil if close.nil? inner = rest[1...close] return nil if inner.include?("(") # nested parens unsupported tokens << [:cond, inner.strip.downcase] rest = rest[(close + 1)..] elsif (match = rest.match(/\A[a-z][a-z0-9-]*/i)) tokens << match[0].downcase rest = rest[match[0].length..] return nil unless rest.empty? || rest.start_with?("(") || rest.match?(/\A\s/) else return nil end end tokens end |