// // Copyright 2020 Google Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // @use 'sass:selector'; @use 'sass:string'; @use 'sass:list'; @use 'sass:map'; @use 'sass:meta'; /// Global variable used to conditionally emit CSS selector fallback /// declarations in addition to CSS custom property overrides for IE11 support. /// Use `enable-css-selector-fallback-declarations()` mixin to configure this /// flag. /// /// @example /// /// @include shadow-dom.enable-css-selector-fallback-declarations(); /// @include foo-bar-theme.theme($theme); /// /// CSS output => /// /// --foo-bar: red; /// /// // Fallback declarations for IE11 support /// .mdc-foo-bar__baz { /// color: red; /// } $css-selector-fallback-declarations: false; /// Enables CSS selector fallback declarations for IE11 support by setting /// global variable `$css-selector-fallback-declarations` to true. Call this /// mixin before theme mixin call. /// @param {Boolean} $enable Set to `true` to emit CSS selector fallback /// declarations. /// @example /// @include shadow-dom.enable-css-selector-fallback-declarations() /// @include foo-bar-theme.theme($theme); @mixin enable-css-selector-fallback-declarations($enable) { $css-selector-fallback-declarations: $enable !global; } $_host: ':host'; $_host-parens: ':host('; $_end-parens: ')'; /// @deprecated - Use selector-ext.append-strict() instead: /// /// @example - scss /// :host([outlined]), :host, :host button { /// @include selector-ext.append-strict(&, ':hover') { /// --my-custom-prop: blue; /// } /// } /// /// @example - css /// :host([outlined]:hover), :host(:hover), :host button:hover { /// --my-custom-prop: blue; /// } /// /// @example - scss /// :host([outlined]), :host, :host button { /// @at-root { /// #{selector-ext.append-strict(&, ':hover')}, /// & { /// --my-custom-prop: blue; /// } /// } /// } /// /// @example - css /// :host([outlined]:hover), :host(:hover), :host button:hover, /// :host([outlined]), :host, :host button { /// --my-custom-prop: blue; /// } /// /// Given one or more selectors, this mixin will fix any invalid `:host` parent /// nesting by adding parentheses or inserting the nested selector into the /// parent `:host()` selector's parentheses. The content block provided to /// this mixin /// will be projected under the new selectors. /// /// @example /// :host([outlined]), :host, :host button { /// @include host-aware(selector.append(&, ':hover'), &)) { /// --my-custom-prop: blue; /// } /// } /// /// will output (but with selectors on a single line): /// :host([outlined]:hover), // Appended :hover argument /// :host(:hover), /// :host button:hover, /// :host([outlined]), // Ampersand argument /// :host, /// :host button, { /// --my-custom-prop: blue; /// }; /// /// @param {List} $selector-args - One or more selectors to be fixed for invalid /// :host syntax. @mixin host-aware($selector-args...) { @each $selector in $selector-args { @if not _is-sass-selector($selector) { @error 'mdc-theme: host-aware() expected a sass:selector value type but received #{$selector}'; } } @if not _share-common-parent($selector-args...) { @error 'mdc-theme: host-aware() requires all selectors to use the parent selector (&)'; } $selectors: _flatten-selectors($selector-args...); $processed-selectors: (); @each $selector in $selectors { $first-selector: list.nth($selector, 1); @if _host-selector-needs-to-be-fixed($first-selector) { $selector: list.set-nth( $selector, 1, _fix-host-selector($first-selector) ); $processed-selectors: list.append( $processed-selectors, $selector, $separator: comma ); } @else { // Either not in :host, or there are more selectors following the :host // and nothing needs to be modified. The content can be placed within the // original selector $processed-selectors: list.append( $processed-selectors, $selector, $separator: comma ); } } @if list.length($processed-selectors) > 0 { @at-root { #{$processed-selectors} { @content; } } } } /// Determines whether a selector needs to be processed. /// Selectors that need to be processed would include anything of the format /// `^:host(\(.*\))?.+` e.g. `:host([outlined]):hover` or `:host:hover` but not /// `:host` or `:host([outlined])` /// /// @param {String} $selector - Selector string to be processed /// @return {Boolean} Whether or not the given selector string needs to be fixed /// for an invalid :host selector @function _host-selector-needs-to-be-fixed($selector) { $host-index: string.index($selector, $_host); $begins-with-host: $host-index == 1; @if not $begins-with-host { @return false; } $_host-parens-index: _get-last-end-parens-index($selector); $has-parens: $_host-parens-index != null; @if $has-parens { // e.g. :host(.inside).after -> needs to be fixed // :host(.inside) -> does not need to be fixed $end-parens-index: string.index($selector, $_end-parens); $content-after-parens: string.slice($selector, $end-parens-index + 1); $has-content-after-parens: string.length($selector) > $end-parens-index; @return $has-content-after-parens; } @else { // e.g. :host.after -> needs to be fixed // :host -> does not need to be fixed $has-content-after-host: $selector != $_host; @return $has-content-after-host; } } /// Flattens a list of selectors /// /// @param {List} $selector-args - A list of selectors to flatten /// @return {List} Flattened selectors @function _flatten-selectors($selector-args...) { $selectors: (); @each $selector-list in $selector-args { $selectors: list.join($selectors, $selector-list); } @return $selectors; } /// Fixes an invalid `:host` selector of the format `^:host(\(.*\))?.+` to /// `:host(.+)` /// @example /// @debug _fix-host-selector(':host:hover') // :host(:hover) /// @debug _fix-host-selector(':host([outlined]):hover) // :host([outlined]:hover) /// /// @param {String} $selector - Selector string to be fixed that follows the /// following format: `^:host(\(.*\))?.+` /// @return {String} Fixed host selector. @function _fix-host-selector($selector) { $_host-parens-index: string.index($selector, $_host-parens); $has-parens: $_host-parens-index != null; $new-host-inside: ''; @if $has-parens { // e.g. :host(.inside).after -> :host(.inside.after) $end-parens-index: _get-last-end-parens-index($selector); $inside-host-parens: string.slice( $selector, string.length($_host-parens) + 1, $end-parens-index - 1 ); $after-host-parens: string.slice($selector, $end-parens-index + 1); $new-host-inside: $inside-host-parens + $after-host-parens; } @else { // e.g. :host.after -> :host(.after) $new-host-inside: string.slice($selector, string.length($_host) + 1); } @return ':host(#{$new-host-inside})'; } /// Returns the index of the final occurrence of the end-parenthesis in the /// given string or null if there is none. /// /// @param {String} $string - The string to be searched /// @return {null|Number} @function _get-last-end-parens-index($string) { $index: string.length($string); @while $index > 0 { $char: string.slice($string, $index, $index); @if $char == $_end-parens { @return $index; } $index: $index - 1; } @return null; } /// Returns true if the provided List of Sass selectors share a common parent /// selector. This function ensures that the parent selector (`&`) is used with /// `host-aware()`. /// /// @example /// _share-common-parent( /// ('.foo:hover'), ('.foo' '.bar'), ('.baz' '.foo') /// ); // true /// /// _share-common-parent( /// ('.foo:hover'), ('.foo' '.bar'), ('.baz' '.bar') /// ); // false /// /// The purpose of this function is to make sure that a group of selectors do /// not violate Sass nesting rules. Due to the dynamic nature of `host-aware()`, /// it's possible to provide invalid selector combinations. /// /// @example /// // Valid native nesting /// :host { /// &:hover, /// .foo, /// .bar & { /// color: blue; /// } /// } /// // Valid host-aware() nesting /// :host { /// @include host-aware( /// selector.append(&, ':hover'), /// selector.nest(&, '.foo'), /// selector.nest('.bar', &), /// ) { /// color: blue; /// } /// } /// // Output /// :host(:hover), /// :host .foo, /// .bar :host { /// color: blue; /// } /// /// // Invalid use of host-aware() /// :host { /// @include host-aware( /// selector.append(&, ':hover'), /// selector.parse('.foo') // Does not share a common parent via `&` /// ) { /// color: blue; /// } /// } /// // Invalid output: no way to write this natively without using @at-root /// :host(:hover), /// .foo { /// color: blue; /// } /// /// @param {Arglist} $selector-lists - An argument list of Sass selectors. /// @return true if the selectors share a common parent selector, or false /// if not. @function _share-common-parent($selector-lists...) { // To validate, this function will extract the simple selectors from each // complex selector and compare them to each other. Every complex selector // should share at least one common simple parent selector. // // We do this by keeping track of each simple selector and if they're present // within a complex selector. At the end of checking all the selectors, at // least one of simple selectors should have been seen for each one of the // complex selectors. // // Each selector list index needs to track its own selector count Map. This is // because each comma-separated list has its own root parent selector that // we're looking for: // .foo, // .bar { // &:hover, // .baz & { ... } // } // ('.foo:hover', '.bar:hover'), ('.baz' '.foo', '.baz' '.bar') // // In the first index of each selector list, we're looking for the parent // ".foo". In the second index we're looking for the parent ".bar". $selector-counts-by-index: (); $expected-counts-by-index: (); @each $selector-list in $selector-lists { @each $complex-selector in $selector-list { $selector-list-index: list.index($selector-list, $complex-selector); $selector-count-map: map.get( $selector-counts-by-index, $selector-list-index ); @if not $selector-count-map { $selector-count-map: (); } $expected-count: map.get($expected-counts-by-index, $selector-list-index); @if not $expected-count { $expected-count: 0; } $simple-selectors-set: (); @each $selector in $complex-selector { @each $simple-selector in selector.simple-selectors($selector) { // Don't use list.join() because there may be duplicate selectors // within the complex selector. We want to treat $simple-selectors-set // like a Set where there are no duplicate values so that we don't // mess up our count by counting one simple selector too many times // for a single complex selector. @if not list.index($simple-selectors-set, $simple-selector) { $simple-selectors-set: list.append( $simple-selectors-set, $simple-selector ); } } } // Now that we have a "Set" of simple selectors for this complex // selector, we can go through each one and update the selector count Map. @each $simple-selector in $simple-selectors-set { $count: map.get($selector-count-map, $simple-selector); @if $count { $count: $count + 1; } @else { $count: 1; } $selector-count-map: map.merge( $selector-count-map, ( $simple-selector: $count, ) ); } $selector-counts-by-index: map.merge( $selector-counts-by-index, ( $selector-list-index: $selector-count-map, ) ); $expected-counts-by-index: map.merge( $expected-counts-by-index, ( $selector-list-index: $expected-count + 1, ) ); } } @each $index, $selector-count-map in $selector-counts-by-index { // If one of the selectors was seen the expected number of times, then we // can reasonably assume that each selector shares a common parent. // Verify for each index if there are multiple parents. $found-parent: false; @each $selector, $count in $selector-count-map { $expected-count: map.get($expected-counts-by-index, $index); @if $count == $expected-count { $found-parent: true; } } @if not $found-parent { @return false; } } // A common parent was found for each selector, or there were no selectors // provided and we did not enter any for loops. @return true; } /// Returns true if the value is a Sass selector type. /// /// Selector types are a 2D List: a comma-separated list (the selector list) /// that contains space-separated lists (the complex selectors) that contain /// unquoted strings (the compound selectors). /// @link https://sass-lang.com/documentation/modules/selector /// /// @example /// .foo, .bar button:hover { } /// $type: ((unquote('.foo')), (unquote('.bar') unquote('button:hover')),); /// /// @param {*} $selector-list - A value to check. /// @return {Boolean} true if the value is a Sass selector, or false if not. @function _is-sass-selector($selector-list) { // For the purposes of these utility functions, we don't care if the lists // have the correct separated or if the strings are unquoted. All that // matters is that the type is a 2D array and the values are strings to // ensure "close enough" that the selector was generated by Sass. // // This function is primarily a safe-guard against an accidental string // slipping in and forgetting to use a selector.append() which would cause a // hard-to-debug problem. @if meta.type-of($selector-list) != 'list' { @return false; } // First level is the selector list: what's separated by commas // e.g. ".foo, .bar" @each $complex-selector in $selector-list { // Second level is the complex selector: what's separated by spaces // e.g. ".foo .bar" @if meta.type-of($complex-selector) != 'list' { @return false; } // Third level is the compound selector: the actual string // e.g. ".foo" @each $selector in $complex-selector { @if meta.type-of($selector) != 'string' { @return false; } } } @return true; }