$VIEWPORTS_CONFIG: up-to 480px it-is "tiny", up-to 768px it-is "small", up-to 1024px it-is "medium", beyond-that it-is "large" !default; // What this configuration means is: // // 0px 480px 768px 1024px >9000px // \__________/\__________/\__________/\____________... // "tiny" "small" "medium" "large" // <-480 481-768 769-1024 1025-> $VIEWPORTS_DEBUG: false !default; $VIEWPORTS_QUERY_TYPES: 'up-to' 'up-from' 'below' 'above' 'among' 'not'; $VIEWPORTS_DEFAULT_QUERY: 'among'; // Returns a boolean value indicating if the current $VIEWPORTS_CONFIG is properly formatted. // // Emits warnings accordingly, to help in debugging the config. // // @return boolean // @function validate-config() { @if type-of(nth($VIEWPORTS_CONFIG, 1)) != 'list' { @warn 'Malformed config (do you have at least one "up-to" and a "beyond-that" specification?)'; @return false; } $uppermostRangeFound: false; @for $i from 1 to length($VIEWPORTS_CONFIG) + 1 { // for each config row $row: nth($VIEWPORTS_CONFIG, $i); $isStandardRange: length($row) == 4 and nth($row, 1) == 'up-to' and nth($row, 3) == 'it-is'; $isUppermostRange: length($row) == 3 and nth($row, 1) == 'beyond-that' and nth($row, 2) == 'it-is'; @if $isUppermostRange { $uppermostRangeFound: true; } @if not($isStandardRange) and not($isUppermostRange) { @warn 'Invalid range specification format: <#{$row}>'; @return false; } @else if $isUppermostRange and $i == 1 { @warn 'Found a "beyond-that" specification with no lower bound (e.g. it can\'t come first)'; @return false; } @else if $isUppermostRange and $i != length($VIEWPORTS_CONFIG) { @warn 'Found a misplaced "beyond-that" specification (it should always come last)'; @return false; } } @if not($uppermostRangeFound) { @warn 'Missing a "beyond-that" specification'; @return false; } @return true; } // Returns the list of configured named ranges. // // @return list // @function range-names() { $return: (); @for $i from 1 to length($VIEWPORTS_CONFIG) + 1 { // for each config row $row: nth($VIEWPORTS_CONFIG, $i); @if nth($row, 1) == 'up-to' { // standard range specification $return: append($return, nth($row, 4)); } @else if nth($row, 1) == 'beyond-that' { // uppermost range specification $return: append($return, nth($row, 3)); } } @return $return; } // Returns a tuple representing the range in which the given $namedRange applies. // For ranges that are unbound at either end, returns for that end. // // @example "small" => [ 481px, 768px ] // @example "large" => [ 1025px, false ] // // Emits a warning if the range isn't found. // // @return list // @function bounds-for-range($namedRange) { @for $i from 1 to length($VIEWPORTS_CONFIG) + 1 { // for each config row $row: nth($VIEWPORTS_CONFIG, $i); @if nth($row, 1) == 'up-to' { // standard range specification @if nth($row, 4) == $namedRange { // name matches @if $i > 1 { // range with a lower neighbor $prevRow: nth($VIEWPORTS_CONFIG, $i - 1); @return nth($prevRow, 2) + 1, nth($row, 2); // return with [lbound, ubound] } @else { // lowest range (e.g. from 0) @return false, nth($row, 2); // return with [false, ubound] } } } @else if nth($row, 1) == 'beyond-that' { // uppermost range specification @if nth($row, 3) == $namedRange { // name matches $prevRow: nth($VIEWPORTS_CONFIG, $i - 1); @return nth($prevRow, 2) + 1, false; // return with [lbound, false] } } } @warn 'Unknown named range: <#{$namedRange}>'; @return false, false; } // Returns a media query expression for the given bounds. // // @example [ 481px, 768px ] => "(min-width: 481px) and (max-width: 768px)" // @example [ 481px, false ] => "(min-width: 481px)" // // @return string // @function media-query-for-bounds($bounds) { @if length($bounds) == 2 { @if nth($bounds, 1) != false and nth($bounds, 2) != false { @return '(min-width: #{nth($bounds, 1)}) and (max-width: #{nth($bounds, 2)})'; } @else if nth($bounds, 1) == false and nth($bounds, 2) != false { @return '(max-width: #{nth($bounds, 2)})'; } @else if nth($bounds, 1) != false and nth($bounds, 2) == false { @return '(min-width: #{nth($bounds, 1)})'; } } @warn 'Invalid bounds for range: <#{$bounds}>'; @return '(max-width: -1px)'; // this is to keep the @return'ed value still syntactically valid } // Returns the given $list imploded into a string. // // @example list-to-string(12 34 56, ";") => "12;34;56" // @function list-to-string($list, $glueString: ', ') { $return: ''; @for $i from 1 to length($list) + 1 { @if $i == 1 { $return: "#{nth($list, $i)}"; } @else { $return: "#{$return}#{$glueString}#{nth($list, $i)}"; } } @return $return; } // Returns the given list with the HEAD element removed. // // @example rest-in-list((1 2 3)) => (2 3) // @function rest-in-list($list) { $return: (); @for $i from 2 to length($list) + 1 { $return: append($return, nth($list, $i)); } @return $return; } // Returns the bounds corresponding to the given query & named range(s). // // @example "up-from", [ "small" ] => [[ 481px, false ]] // @example "not", [ "small" "medium" ] => [[ false, 480px ], [ 1025px, false ]] // // Returns a boolean value for the two special cases, that is, for a // query that always applies, and for one that never does: // // @example "below", [ "tiny" ] => false // @example "up-from", [ "tiny" ] => true // // @return list | boolean // @function resolve-query($queryType, $namedRanges) { @if length($namedRanges) < 1 { @warn 'Expecting at least 1 range for query type <#{$queryType}>, got 0'; @return false; } @if $queryType == 'among' { $return: (); @each $rangeName in $namedRanges { // this IS one of the named ranges -> include it $return: append($return, bounds-for-range($rangeName)); } @return $return; } @else if $queryType == 'not' { $return: (); @each $rangeName in range-names() { @if not(index($namedRanges, $rangeName)) { // this is NOT one of the named ranges -> include it $return: append($return, bounds-for-range($rangeName)); } } @return $return; } @else { @if length($namedRanges) != 1 { @warn 'Expecting a single range for query type <#{$queryType}>, got <#{$namedRanges}> instead'; @return false; } $bounds: bounds-for-range(nth($namedRanges, 1)); @if $queryType == 'up-to' { @if nth($bounds, 2) == false { @return true; // range ALWAYS applies } @else { @return append((), (false, nth($bounds, 2))); } } @else if $queryType == 'up-from' { @if nth($bounds, 1) == false { @return true; // range ALWAYS applies } @else { @return append((), (nth($bounds, 1), false)); } } @else if $queryType == 'below' { @if nth($bounds, 1) == false { @return false; // range NEVER applies } @else { @return append((), (false, nth($bounds, 1) - 1px)); } } @else if $queryType == 'above' { @if nth($bounds, 2) == false { @return false; // range NEVER applies } @else { @return append((), (nth($bounds, 2) + 1px, false)); } } @else { @warn 'Internal error: Unknown query type <#{$queryType}>'; @return false; } } } @mixin viewports($queryConfig) { @if validate-config() { $resolved: null; @if not(index($VIEWPORTS_QUERY_TYPES, nth($queryConfig, 1))) { // if first arg isn't a query type... $resolved: resolve-query($VIEWPORTS_DEFAULT_QUERY, $queryConfig); // ...use the default type, and treat all args as range names } @else { // ...otherwise first arg is the type, and rest should be treated as range names $resolved: resolve-query(nth($queryConfig, 1), rest-in-list($queryConfig)); } @if $VIEWPORTS_DEBUG { @debug '@mixin viewports(<#{$queryConfig}>: resolved query to ranges <#{$resolved}>'; } @if $resolved == true { // @content should ALWAYS apply (e.g. a range unbound at both ends) @content; } @else if $resolved == false { // @content should NEVER apply (e.g. impossible range selection) // nothing } @else { // @content applies to specific ranges of viewports $mediaQueryList: (); @each $bounds in $resolved { $mediaQueryList: append($mediaQueryList, media-query-for-bounds($bounds)); } $mediaQueryList: list-to-string($mediaQueryList, ', '); @media #{$mediaQueryList} { @content; } } } } // When enabled, output CSS that displays the current viewport size in the top-left corner of @if $VIEWPORTS_DEBUG { body::after { position: fixed; z-index: 9001; // it's over nine thousand top: 0; left: 0; margin: 10px; padding: 10px 20px; background: white; border: 1px solid black; color: black; font-family: Monaco, "Liberation Mono", Courier, monospace; font-size: 12px; @if validate-config() { $rangeNames: range-names(); @for $i from 1 to length($rangeNames) + 1 { @include viewports(among nth($rangeNames, $i)) { content: "viewport: #{nth($rangeNames, $i)} (#{$i}/#{length($rangeNames)})"; } } } @else { content: 'Invalid $VIEWPORTS_CONFIG (see sass output)'; color: red; } } }