diff --git a/src/material-experimental/theming/_config-validation.scss b/src/material-experimental/theming/_config-validation.scss index 45d2b08121bb..c43dbcfa7c9e 100644 --- a/src/material-experimental/theming/_config-validation.scss +++ b/src/material-experimental/theming/_config-validation.scss @@ -1,6 +1,52 @@ @use 'sass:list'; @use 'sass:map'; +@use 'sass:meta'; +@use 'sass:string'; @use '@angular/material' as mat; +@use './m3-palettes'; + +/// Creates an error message by finding `$config` in the existing message and appending a suffix to +/// it. +/// @param {List|String} $err The error message. +/// @param {String} $suffix The suffix to add. +/// @return {List|String} The updated error message. +@function _create-dollar-config-error-message($err, $suffix) { + @if meta.type-of($err) == 'list' { + @for $i from 1 through list.length($err) { + $err: list.set-nth($err, $i, + _create-dollar-config-error-message(list.nth($err, $i), $suffix)); + } + } + @else if meta.type-of($err) == 'string' { + $start: string.index($err, '$config'); + @if $start { + $err: string.insert($err, $suffix, $start + 7); + } + } + @return $err; +} + +/// Validates that the given object is an M3 palette. +/// @param {*} $palette The object to test +/// @return {Boolean|null} null if it is a valid M3 palette, else true. +@function _validate-palette($palette) { + @if not meta.type-of($palette) == 'map' { + @return true; + } + $keys: map.keys($palette); + $expected-keys: map.keys(m3-palettes.$red-palette); + @if mat.private-validate-allowed-values($keys, $expected-keys...) or + mat.private-validate-required-values($keys, $expected-keys...) { + @return true; + } + $nv-keys: map.keys(map.get($palette, neutral-variant)); + $expected-nv-keys: map.keys(map.get(m3-palettes.$red-palette, neutral-variant)); + @if mat.private-validate-allowed-values($nv-keys, $expected-nv-keys...) or + mat.private-validate-required-values($nv-keys, $expected-nv-keys...) { + @return true; + } + @return null; +} /// Validates a theme config. /// @param {Map} $config The config to test. @@ -8,23 +54,29 @@ @function validate-theme-config($config) { $err: mat.private-validate-type($config, 'map', 'null'); @if $err { - @return (#{'$config'} #{'should be a color configuration object. Got:'} $config); + @return (#{'$config should be a configuration object. Got:'} $config); } - $err: mat.private-validate-allowed-values(map.keys($config or ()), color, typography, density); + $allowed: (color, typography, density); + $err: mat.private-validate-allowed-values(map.keys($config or ()), $allowed...); @if $err { - @return (#{'$config'} #{'has unexpected properties:'} $err); + @return ( + #{'$config has unexpected properties. Valid properties are'} + #{'#{$allowed}.'} + #{'Found:'} + $err + ); } $err: validate-color-config(map.get($config, color)); @if $err { - @return list.set-nth($err, 1, #{'#{list.nth($err, 1)}.color'}); + @return _create-dollar-config-error-message($err, '.color'); } $err: validate-typography-config(map.get($config, typography)); @if $err { - @return list.set-nth($err, 1, #{'#{list.nth($err, 1)}.typography'}); + @return _create-dollar-config-error-message($err, '.typography'); } $err: validate-density-config(map.get($config, density)); @if $err { - @return list.set-nth($err, 1, #{'#{list.nth($err, 1)}.density'}); + @return _create-dollar-config-error-message($err, '.density'); } @return null; } @@ -35,12 +87,35 @@ @function validate-color-config($config) { $err: mat.private-validate-type($config, 'map', 'null'); @if $err { - @return (#{'$config'} #{'should be a color configuration object. Got:'} $config); + @return (#{'$config should be a color configuration object. Got:'} $config); } - $err: mat.private-validate-allowed-values( - map.keys($config or ()), theme-type, primary, secondary, tertiary); + $allowed: (theme-type, primary, secondary, tertiary); + $err: mat.private-validate-allowed-values(map.keys($config or ()), $allowed...); @if $err { - @return (#{'$config'} #{'has unexpected properties:'} $err); + @return ( + #{'$config has unexpected properties. Valid properties are'} + #{'#{$allowed}.'} + #{'Found:'} + $err + ); + } + @if $config and map.has-key($config, theme-type) and + not list.index((light, dark), map.get($config, theme-type)) { + @return ( + #{'Expected $config.theme-type to be one of: light, dark. Got:'} + map.get($config, theme-type) + ); + } + @each $palette in (primary, secondary, tertiary) { + @if $config and map.has-key($config, $palette) { + $err: _validate-palette(map.get($config, $palette)); + @if $err { + @return ( + #{'Expected $config.#{$palette} to be a valid M3 palette. Got:'} + map.get($config, $palette) + ); + } + } } @return null; } @@ -51,13 +126,17 @@ @function validate-typography-config($config) { $err: mat.private-validate-type($config, 'map', 'null'); @if $err { - @return (#{'$config'} #{'should be a typography configuration object. Got:'} $config); + @return (#{'$config should be a typography configuration object. Got:'} $config); } - $err: mat.private-validate-allowed-values( - map.keys($config or ()), brand-family, plain-family, bold-weight, medium-weight, - regular-weight); + $allowed: (brand-family, plain-family, bold-weight, medium-weight, regular-weight); + $err: mat.private-validate-allowed-values(map.keys($config or ()), $allowed...); @if $err { - @return (#{'$config'} #{'has unexpected properties:'} $err); + @return ( + #{'$config has unexpected properties. Valid properties are'} + #{'#{$allowed}.'} + #{'Found:'} + $err + ); } @return null; } @@ -68,11 +147,20 @@ @function validate-density-config($config) { $err: mat.private-validate-type($config, 'map', 'null'); @if $err { - @return (#{'$config'} #{'should be a density configuration object. Got:'} $config); + @return (#{'$config should be a density configuration object. Got:'} $config); } $err: mat.private-validate-allowed-values(map.keys($config or ()), scale); @if $err { - @return (#{'$config'} #{'has unexpected properties:'} $err); + @return (#{'$config has unexpected properties. Valid properties are: scale. Found:'} $err); + } + @if $config and map.has-key($config, scale) { + $allowed-scales: (0, -1, -2, -3, -4 -5, minimum, maximum); + @if mat.private-validate-allowed-values(map.get($config, scale), $allowed-scales...) { + @return ( + #{'Expected $config.scale to be one of: #{$allowed-scales}. Got:'} + map.get($config, scale) + ); + } } @return null; } diff --git a/src/material-experimental/theming/_definition.scss b/src/material-experimental/theming/_definition.scss index 2ce0ff16e245..4119c5b1593a 100644 --- a/src/material-experimental/theming/_definition.scss +++ b/src/material-experimental/theming/_definition.scss @@ -46,6 +46,14 @@ $theme-version: 1; $internals: ( theme-version: $theme-version, theme-type: $type, + palettes: ( + primary: map.remove($primary, neutral-variant), + secondary: map.remove($secondary, neutral-variant), + tertiary: map.remove($tertiary, neutral-variant), + neutral: m3-palettes.$neutral-palette, + neutral-variant: map.get($primary, neutral-variant), + error: m3-palettes.$red-palette + ), color-tokens: m3-tokens.generate-color-tokens($type, $primary, $secondary, $tertiary, m3-palettes.$neutral-palette, m3-palettes.$red-palette) ) @@ -61,7 +69,7 @@ $theme-version: 1; @error $err; } - $plain: map.get($config, plain-family) or Roboto, sans-serif; + $plain: map.get($config, plain-family) or (Roboto, sans-serif); $brand: map.get($config, brand-family) or $plain; $bold: map.get($config, bold-weight) or 700; $medium: map.get($config, medium-weight) or 500; diff --git a/src/material-experimental/theming/_m3-palettes.scss b/src/material-experimental/theming/_m3-palettes.scss index 6df466ea78ca..bcd3e96061a1 100644 --- a/src/material-experimental/theming/_m3-palettes.scss +++ b/src/material-experimental/theming/_m3-palettes.scss @@ -90,6 +90,7 @@ $yellow-palette: ( 90: #e6e3d1, 95: #f4f1df, 99: #fffbff, + 100: #fff, ), ); diff --git a/src/material/_index.scss b/src/material/_index.scss index 8aef4a5a4f4b..e7bf05f9f503 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -141,3 +141,8 @@ private-typography-config-level-from-mdc, private-if-touch-targets-unsupported, $private-mdc-base-styles-query, $private-mdc-base-styles-without-animation-query, $private-mdc-theme-styles-query, $private-mdc-typography-styles-query; + +// New theming APIs, currently in development: +@forward './core/theming/inspection' as private-* show private-get-theme-version, + private-get-theme-type, private-get-theme-color, private-get-theme-typography, + private-get-theme-density, private-theme-has; diff --git a/src/material/core/style/_validation.scss b/src/material/core/style/_validation.scss index 61a515bc9d17..41b9eafe4e1f 100644 --- a/src/material/core/style/_validation.scss +++ b/src/material/core/style/_validation.scss @@ -27,3 +27,17 @@ } @return if(list.length($invalid) > 0, $invalid, null); } + +/// Validates that a list contains all values from the required list of values. +/// @param {List} $list The list to test +/// @param {List} $required The required values +/// @return {List} null if no error, else the list of missing values. +@function validate-required-values($list, $required...) { + $invalid: (); + @each $element in $required { + @if not list.index($list, $element) { + $invalid: list.append($invalid, $element); + } + } + @return if(list.length($invalid) > 0, $invalid, null); +} diff --git a/src/material/core/theming/_inspection.scss b/src/material/core/theming/_inspection.scss index 476fdd72da65..5eaf6180a399 100644 --- a/src/material/core/theming/_inspection.scss +++ b/src/material/core/theming/_inspection.scss @@ -6,6 +6,26 @@ $_internals: _mat-theming-internals-do-not-access; +$_m3-typescales: ( + display-large, + display-medium, + display-small, + headline-large, + headline-medium, + headline-small, + title-large, + title-medium, + title-small, + label-large, + label-medium, + label-small, + body-large, + body-medium, + body-small, +); + +$_typography-properties: (font, font-family, line-height, font-size, letter-spacing, font-weight); + /// Validates that the given value is a versioned theme object. /// @param {Any} $theme The theme object to validate. /// @return {Boolean|Null} true if the theme has errors, else null. @@ -24,6 +44,166 @@ $_internals: _mat-theming-internals-do-not-access; @return if($err, 0, map.get($theme, $_internals, theme-version) or 0); } +/// Gets the type of theme represented by a theme object (light or dark). +/// @param {Map} $theme The theme +/// @return {String} The type of theme (either `light` or `dark`). +@function get-theme-type($theme) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-type does not support legacy theme objects.'}; + } + @if not theme-has($theme, color) { + @error 'Color information is not available on this theme.'; + } + @return map.get($theme, $_internals, theme-type) or light; +} + +/// Gets a color from a theme object. This function can take 2 or 3 arguments. If 2 arguments are +/// passed, the second argument is treated as the name of a color role. If 3 arguments are passed, +/// the second argument is treated as the name of a color palette (primary, secondary, etc.) and the +/// third is treated as the palette hue (10, 50, etc.) +/// @param {Map} $theme The theme +/// @param {String} $color-role-or-palette-name The name of the color role to get, or the name of a +/// color palette. +/// @param {Number} $hue The palette hue to get (passing this argument means the second argument is +/// interpreted as a palette name). +/// @return {Color} The requested theme color. +@function get-theme-color($theme, $args...) { + $args-count: list.length($args); + @if $args-count == 1 { + @return _get-theme-role-color($theme, $args...); + } + @else if $args-count == 2 { + @return _get-theme-palette-color($theme, $args...); + } + @error #{'Expected 2 or 3 arguments. Got:'} $args-count + 1; +} + +/// Gets a role color from a theme object. +/// @param {Map} $theme The theme +/// @param {String} $color-role-name The name of the color role to get. +/// @return {Color} The requested role color. +@function _get-theme-role-color($theme, $color-role-name) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-color does not support legacy theme objects.'}; + } + @if not theme-has($theme, color) { + @error 'Color information is not available on this theme.'; + } + $color-roles: map.get($theme, $_internals, color-tokens, (mdc, theme)); + $result: map.get($color-roles, $color-role-name); + @if not $result { + @error #{'Valid color roles are: #{map.keys($color-roles)}. Got:'} $color-role-name; + } + @return $result; +} + +/// Gets a palette color from a theme object. +/// @param {Map} $theme The theme +/// @param {String} $palette-name The name of the palette to get the color from. +/// @param {Number} $hue The hue to read from the palette. +/// @return {Color} The requested palette color. +@function _get-theme-palette-color($theme, $palette-name, $hue) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-color does not support legacy theme objects.'}; + } + @if not theme-has($theme, color) { + @error 'Color information is not available on this theme.'; + } + $palettes: map.get($theme, $_internals, palettes); + $palette: map.get($palettes, $palette-name); + @if not $palette { + $supported-palettes: map.keys($palettes); + @error #{'Valid palettes are: #{$supported-palettes}. Got:'} $palette-name; + } + $result: map.get($palette, $hue); + @if not $result { + $supported-hues: map.keys($palette); + @error #{'Valid hues for'} $palette-name #{'are: #{$supported-hues}. Got:'} $hue; + } + @return $result; +} + +/// Gets a typography value from a theme object. +/// @param {Map} $theme The theme +/// @param {String} $typescale The typescale name. +/// @param {String} $property The CSS font property to get +/// (font, font-family, font-size, font-weight, line-height, or letter-spacing). +/// @return {*} The value of the requested font property. +@function get-theme-typography($theme, $typescale, $property: font) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-typography does not support legacy theme objects.'}; + } + @if not theme-has($theme, typography) { + @error 'Typography information is not available on this theme.'; + } + @if not list.index($_m3-typescales, $typescale) { + @error #{'Valid typescales are: #{$_m3-typescales}. Got:'} $typescale; + } + @if not list.index($_typography-properties, $property) { + @error #{'Valid typography properties are: #{$_typography-properties}. Got:'} $property; + } + $property-key: map.get(( + font: '', + font-family: '-font', + line-height: '-line-height', + font-size: '-size', + letter-spacing: '-tracking', + font-weight: '-weight' + ), $property); + $token-name: '#{$typescale}#{$property-key}'; + @return map.get($theme, $_internals, typography-tokens, (mdc, typography), $token-name); +} + +/// Gets the density scale from a theme object. +/// @param {Map} $theme The theme +/// @return {Number} The density scale. +@function get-theme-density($theme) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-density does not support legacy theme objects.'}; + } + @if not theme-has($theme, density) { + @error 'Density information is not available on this theme.'; + } + @return map.get($theme, $_internals, density-scale); +} + +/// Checks whether the theme has information about given theming system. +/// @param {Map} $theme The theme +/// @param {String} $system The system to check +/// @param {Boolean} Whether the theme has information about the system. +@function theme-has($theme, $system) { + $err: _validate-theme-object($theme); + @if $err { + // TODO(mmalerba): implement for old style theme objects. + @error #{'get-theme-density does not support legacy theme objects.'}; + } + @if $system == base { + @return map.get($theme, $_internals, base-tokens) != null; + } + @if $system == color { + @return map.get($theme, $_internals, color-tokens) != null and + map.get($theme, $_internals, theme-type) != null and + map.get($theme, $_internals, palettes) != null; + } + @if $system == typography { + @return map.get($theme, $_internals, typography-tokens) != null; + } + @if $system == density { + @return map.get($theme, $_internals, density-scale) != null; + } + @error 'Valid systems are: base, color, typography, density. Got:' $system; +} + /// Gets the set of tokens from the given theme, limited to those affected by the requested theming /// systems. /// @param {Map} $theme The theme to get tokens from. @@ -38,9 +218,8 @@ $_internals: _mat-theming-internals-do-not-access; } $err: validation.validate-allowed-values($systems, color, typography, density, base); @if $err { - @error - #{'Expected $systems to contain valid system names (color, typographt, density, or base).'} - #{'Got invalid system names:'} $err; + @error #{'Expected $systems to contain valid system names (color, typography, density, or'} + #{'base). Got invalid system names:'} $err; } $result: (); @each $system in $systems { diff --git a/src/material/core/theming/tests/BUILD.bazel b/src/material/core/theming/tests/BUILD.bazel index 955b14c77b46..4d2657c60c53 100644 --- a/src/material/core/theming/tests/BUILD.bazel +++ b/src/material/core/theming/tests/BUILD.bazel @@ -56,7 +56,9 @@ ts_library( name = "unit_test_lib", testonly = True, srcs = [ - "theming-api.spec.ts", + "theming-definition-api.spec.ts", + "theming-inspection-api.spec.ts", + "theming-mixin-api.spec.ts", ], # TODO(ESM): remove this once the Bazel NodeJS rules can handle ESM with `nodejs_binary`. devmode_module = "commonjs", @@ -74,6 +76,9 @@ ts_library( jasmine_node_test( name = "unit_tests", srcs = [":unit_test_lib"], - data = ["//src/material:sass_lib"], + data = [ + "//src/material:sass_lib", + "//src/material-experimental:sass_lib", + ], shard_count = 4, ) diff --git a/src/material/core/theming/tests/theming-definition-api.spec.ts b/src/material/core/theming/tests/theming-definition-api.spec.ts new file mode 100644 index 000000000000..f0b116375482 --- /dev/null +++ b/src/material/core/theming/tests/theming-definition-api.spec.ts @@ -0,0 +1,303 @@ +import {parse} from 'postcss'; +import {compileString} from 'sass'; +import {runfiles} from '@bazel/runfiles'; +import * as path from 'path'; + +import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer'; +import {pathToFileURL} from 'url'; + +// Note: For Windows compatibility, we need to resolve the directory paths through runfiles +// which are guaranteed to reside in the source tree. +const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests'); +const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); + +const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); + +const mdcSassImporter = { + findFileUrl: (url: string) => { + if (url.toString().startsWith('@material')) { + return pathToFileURL(path.join(runfiles.resolveWorkspaceRelative('./node_modules'), url)); + } + return null; + }, +}; + +/** Transpiles given Sass content into CSS. */ +function transpile(content: string) { + return compileString( + ` + @use 'sass:list'; + @use 'sass:map'; + @use '../../../index' as mat; + @use '../../../../material-experimental/index' as matx; + + $internals: _mat-theming-internals-do-not-access; + + ${content} + `, + { + loadPaths: [testDir], + importers: [localPackageSassImporter, mdcSassImporter], + }, + ).css.toString(); +} + +function getRootVars(css: string) { + const result: {[key: string]: string} = {}; + parse(css).each(node => { + if (node.type === 'rule' && node.selector === ':root') { + node.walk(child => { + if (child.type === 'decl') { + if (child.prop.startsWith('--')) { + result[child.prop.substring(2)] = child.value; + } + } + }); + } + }); + return result; +} + +describe('theming definition api', () => { + describe('define-theme', () => { + it('should fill in defaults', () => { + const css = transpile(` + $theme: matx.define-theme(); + $data: map.get($theme, $internals); + :root { + --keys: #{map.keys($data)}; + --version: #{map.get($data, theme-version)}; + --type: #{map.get($data, theme-type)}; + --palettes: #{map.keys(map.get($data, palettes))}; + --density: #{map.get($data, density-scale)}; + --base-tokens: #{list.length(map.get($data, base-tokens)) > 0}; + --color-tokens: #{list.length(map.get($data, color-tokens)) > 0}; + --typography-tokens: #{list.length(map.get($data, typography-tokens)) > 0}; + --density-tokens: #{list.length(map.get($data, density-tokens)) > 0}; + } + `); + const vars = getRootVars(css); + expect(vars['keys'].split(', ')).toEqual([ + 'theme-version', + 'theme-type', + 'palettes', + 'color-tokens', + 'typography-tokens', + 'density-scale', + 'density-tokens', + 'base-tokens', + ]); + expect(vars['version']).toBe('1'); + expect(vars['type']).toBe('light'); + expect(vars['palettes'].split(', ')).toEqual([ + 'primary', + 'secondary', + 'tertiary', + 'neutral', + 'neutral-variant', + 'error', + ]); + expect(vars['density']).toBe('0'); + expect(vars['base-tokens']).toBe('true'); + expect(vars['color-tokens']).toBe('true'); + expect(vars['typography-tokens']).toBe('true'); + expect(vars['density-tokens']).toBe('true'); + }); + + it('should customize colors', () => { + const css = transpile(` + $theme: matx.define-theme(( + color: ( + theme-type: dark, + primary: matx.$m3-yellow-palette, + secondary: matx.$m3-orange-palette, + tertiary: matx.$m3-red-palette, + ) + )); + $data: map.get($theme, $internals); + :root { + --token-surface: #{map.get($data, color-tokens, (mdc, theme), surface)}; + --token-primary: #{map.get($data, color-tokens, (mdc, theme), primary)}; + --token-secondary: #{map.get($data, color-tokens, (mdc, theme), secondary)}; + --token-tertiary: #{map.get($data, color-tokens, (mdc, theme), tertiary)}; + --palette-primary: #{map.get($data, palettes, primary, 50)}; + --palette-secondary: #{map.get($data, palettes, secondary, 50)}; + --palette-tertiary: #{map.get($data, palettes, tertiary, 50)}; + --type: #{map.get($data, theme-type)}; + } + `); + const vars = getRootVars(css); + expect(vars['token-surface']).toBe('#1c1b1f'); + expect(vars['token-primary']).toBe('#cdcd00'); + expect(vars['token-secondary']).toBe('#ffb95c'); + expect(vars['token-tertiary']).toBe('#ffb4a8'); + expect(vars['palette-primary']).toBe('#7b7b00'); + expect(vars['palette-secondary']).toBe('#a66a00'); + expect(vars['palette-tertiary']).toBe('#ef0000'); + expect(vars['type']).toBe('dark'); + }); + + it('should customize typography', () => { + const css = transpile(` + $theme: matx.define-theme(( + typography: ( + brand-family: Comic Sans, + plain-family: Wingdings, + bold-weight: 300, + medium-weight: 200, + regular-weight: 100, + ) + )); + $data: map.get($theme, $internals); + :root { + --display-font: + #{map.get($data, typography-tokens, (mdc, typography), display-large-font)}; + --display-weight: + #{map.get($data, typography-tokens, (mdc, typography), display-large-weight)}; + --title-font: + #{map.get($data, typography-tokens, (mdc, typography), title-small-font)}; + --title-weight: + #{map.get($data, typography-tokens, (mdc, typography), title-small-weight)}; + } + `); + const vars = getRootVars(css); + expect(vars['display-font']).toBe('Comic Sans'); + expect(vars['display-weight']).toBe('100'); + expect(vars['title-font']).toBe('Wingdings'); + expect(vars['title-weight']).toBe('200'); + }); + + it('should customize density', () => { + const css = transpile(` + $theme: matx.define-theme(( + density: ( + scale: -2 + ) + )); + $data: map.get($theme, $internals); + :root { + --size: #{map.get($data, density-tokens, (mdc, checkbox), state-layer-size)}; + } + `); + const vars = getRootVars(css); + expect(vars['size']).toBe('32px'); + }); + + it('should throw for invalid system config', () => { + expect(() => transpile(`$theme: matx.define-theme(5)`)).toThrowError( + /\$config should be a configuration object\. Got: 5/, + ); + }); + + it('should throw for invalid color config', () => { + expect(() => transpile(`$theme: matx.define-theme((color: 5))`)).toThrowError( + /\$config\.color should be a color configuration object\. Got: 5/, + ); + }); + + it('should throw for invalid typography config', () => { + expect(() => transpile(`$theme: matx.define-theme((typography: 5))`)).toThrowError( + /\$config\.typography should be a typography configuration object\. Got: 5/, + ); + }); + + it('should throw for invalid density config', () => { + expect(() => transpile(`$theme: matx.define-theme((density: 5))`)).toThrowError( + /\$config\.density should be a density configuration object\. Got: 5/, + ); + }); + + it('should throw for invalid config property', () => { + expect(() => transpile(`$theme: matx.define-theme((fake: 5))`)).toThrowError( + /\$config has unexpected properties.*Found: fake/, + ); + }); + + it('should throw for invalid color property', () => { + expect(() => transpile(`$theme: matx.define-theme((color: (fake: 5)))`)).toThrowError( + /\$config\.color has unexpected properties.*Found: fake/, + ); + }); + + it('should throw for invalid typography property', () => { + expect(() => transpile(`$theme: matx.define-theme((typography: (fake: 5)))`)).toThrowError( + /\$config\.typography has unexpected properties.*Found: fake/, + ); + }); + + it('should throw for invalid density property', () => { + expect(() => transpile(`$theme: matx.define-theme((density: (fake: 5)))`)).toThrowError( + /\$config\.density has unexpected properties.*Found: fake/, + ); + }); + + it('should throw for invalid theme type', () => { + expect(() => + transpile(`$theme: matx.define-theme((color: (theme-type: black)))`), + ).toThrowError(/Expected \$config\.color.theme-type to be one of:.*Got: black/); + }); + + it('should throw for invalid palette', () => { + expect(() => + transpile(`$theme: matx.define-theme((color: (tertiary: mat.$red-palette)))`), + ).toThrowError(/Expected \$config\.color\.tertiary to be a valid M3 palette\. Got:/); + }); + + it('should throw for invalid density scale', () => { + expect(() => transpile(`$theme: matx.define-theme((density: (scale: 10)))`)).toThrowError( + /Expected \$config\.density\.scale to be one of:.*Got: 10/, + ); + }); + }); + + describe('define-colors', () => { + it('should omit non-color info', () => { + const css = transpile(` + $theme: matx.define-colors(); + $data: map.get($theme, $internals); + :root { + --keys: #{map.keys($data)}; + } + `); + const vars = getRootVars(css); + expect(vars['keys'].split(', ')).toEqual([ + 'theme-version', + 'theme-type', + 'palettes', + 'color-tokens', + ]); + }); + }); + + describe('define-typography', () => { + it('should omit non-typography info', () => { + const css = transpile(` + $theme: matx.define-typography(); + $data: map.get($theme, $internals); + :root { + --keys: #{map.keys($data)}; + } + `); + const vars = getRootVars(css); + expect(vars['keys'].split(', ')).toEqual(['theme-version', 'typography-tokens']); + }); + }); + + describe('define-density', () => { + it('should omit non-color info', () => { + const css = transpile(` + $theme: matx.define-density(); + $data: map.get($theme, $internals); + :root { + --keys: #{map.keys($data)}; + } + `); + const vars = getRootVars(css); + expect(vars['keys'].split(', ')).toEqual([ + 'theme-version', + 'density-scale', + 'density-tokens', + ]); + }); + }); +}); diff --git a/src/material/core/theming/tests/theming-inspection-api.spec.ts b/src/material/core/theming/tests/theming-inspection-api.spec.ts new file mode 100644 index 000000000000..9698d5ee294f --- /dev/null +++ b/src/material/core/theming/tests/theming-inspection-api.spec.ts @@ -0,0 +1,287 @@ +import {compileString} from 'sass'; +import {runfiles} from '@bazel/runfiles'; +import * as path from 'path'; + +import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer'; +import {pathToFileURL} from 'url'; + +// Note: For Windows compatibility, we need to resolve the directory paths through runfiles +// which are guaranteed to reside in the source tree. +const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests'); +const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); + +const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); + +const mdcSassImporter = { + findFileUrl: (url: string) => { + if (url.toString().startsWith('@material')) { + return pathToFileURL(path.join(runfiles.resolveWorkspaceRelative('./node_modules'), url)); + } + return null; + }, +}; + +/** Transpiles given Sass content into CSS. */ +function transpile(content: string) { + return compileString( + ` + @use '../../../index' as mat; + @use '../../../../material-experimental/index' as matx; + + ${content} + `, + { + loadPaths: [testDir], + importers: [localPackageSassImporter, mdcSassImporter], + }, + ).css.toString(); +} + +describe('theming inspection api', () => { + describe('for m2 theme', () => { + it('should get theme version', () => { + expect( + transpile(` + $theme: mat.define-light-theme(( + color: ( + primary: mat.define-palette(mat.$red-palette), + accent: mat.define-palette(mat.$red-palette), + warn: mat.define-palette(mat.$red-palette), + ), + typography: mat.define-typography-config(), + density: 0, + )); + div { + --theme-version: #{mat.private-get-theme-version($theme)}; + } + `), + ).toMatch('--theme-version: 0;'); + }); + }); + + describe('for m3 theme', () => { + it('should get theme version', () => { + expect( + transpile(` + $theme: matx.define-theme(); + div { + --theme-version: #{mat.private-get-theme-version($theme)}; + } + `), + ).toMatch('--theme-version: 1;'); + }); + + it('should get theme type', () => { + expect( + transpile(` + $theme: matx.define-theme(); + div { + --theme-type: #{mat.private-get-theme-type($theme)}; + } + `), + ).toMatch('--theme-type: light;'); + }); + + it('should get role color', () => { + expect( + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme, primary-container); + } + `), + ).toMatch('color: #f0dbff;'); + }); + + it('should error on invalid color role', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme, fake-role); + } + `), + ).toThrowError(/Valid color roles are.*Got: fake-role/); + }); + + it('should get palette color', () => { + expect( + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme, tertiary, 20); + } + `), + ).toMatch('color: #4a0080;'); + }); + + it('should error on invalid color palette', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme, fake-palette, 20); + } + `), + ).toThrowError(/Valid palettes are.*Got: fake-palette/); + }); + + it('should error on invalid color hue', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme, neutral, 11); + } + `), + ).toThrowError(/Valid hues for neutral are.*Got: 11/); + }); + + it('should error on wrong number of get-color-theme args', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + color: mat.private-get-theme-color($theme); + } + `), + ).toThrowError(/Expected 2 or 3 arguments. Got: 1/); + }); + + it('should get typography properties from theme', () => { + const css = transpile(` + $theme: matx.define-theme(); + div { + font: mat.private-get-theme-typography($theme, headline-large); + font-family: mat.private-get-theme-typography($theme, headline-large, font-family); + font-size: mat.private-get-theme-typography($theme, headline-large, font-size); + font-weight: mat.private-get-theme-typography($theme, headline-large, font-weight); + line-height: mat.private-get-theme-typography($theme, headline-large, line-height); + letter-spacing: mat.private-get-theme-typography($theme, headline-large, letter-spacing); + } + `); + expect(css).toMatch('font: 400 2rem / 2.5rem Roboto, sans-serif;'); + expect(css).toMatch('font-family: Roboto, sans-serif;'); + expect(css).toMatch('font-size: 2rem;'); + expect(css).toMatch('font-weight: 400;'); + expect(css).toMatch('line-height: 2.5rem;'); + expect(css).toMatch('letter-spacing: 0rem;'); + }); + }); + + it('should error on invalid typescale', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + font: mat.private-get-theme-typography($theme, subtitle-large); + } + `), + ).toThrowError(/Valid typescales are:.*Got: subtitle-large/); + }); + + it('should error on invalid typography property', () => { + expect(() => + transpile(` + $theme: matx.define-theme(); + div { + text-transform: mat.private-get-theme-typography($theme, body-small, text-transform); + } + `), + ).toThrowError(/Valid typography properties are:.*Got: text-transform/); + }); + + it('should get density scale', () => { + expect( + transpile(` + $theme: matx.define-theme(); + div { + --density-scale: #{mat.private-get-theme-density($theme)}; + } + `), + ).toMatch('--density-scale: 0;'); + }); + + it('should check what information the theme has', () => { + const css = transpile(` + $theme: matx.define-theme(); + $color-only: matx.define-colors(); + $typography-only: matx.define-typography(); + $density-only: matx.define-density(); + div { + --base: #{( + mat.private-theme-has($theme, base), + mat.private-theme-has($color-only, base), + mat.private-theme-has($typography-only, base), + mat.private-theme-has($density-only, base), + )}; + --color: #{( + mat.private-theme-has($theme, color), + mat.private-theme-has($color-only, color), + mat.private-theme-has($typography-only, color), + mat.private-theme-has($density-only, color), + )}; + --typography: #{( + mat.private-theme-has($theme, typography), + mat.private-theme-has($color-only, typography), + mat.private-theme-has($typography-only, typography), + mat.private-theme-has($density-only, typography), + )}; + --density: #{( + mat.private-theme-has($theme, density), + mat.private-theme-has($color-only, density), + mat.private-theme-has($typography-only, density), + mat.private-theme-has($density-only, density), + )}; + } + `); + expect(css).toMatch(/--base: true, false, false, false;/); + expect(css).toMatch(/--color: true, true, false, false;/); + expect(css).toMatch(/--typography: true, false, true, false;/); + expect(css).toMatch(/--density: true, false, false, true;/); + }); + + it('should error when reading theme type from a theme with no color information', () => { + expect(() => + transpile(` + $theme: matx.define-density(); + div { + color: mat.private-get-theme-type($theme); + } + `), + ).toThrowError(/Color information is not available on this theme/); + }); + + it('should error when reading color from a theme with no color information', () => { + expect(() => + transpile(` + $theme: matx.define-density(); + div { + color: mat.private-get-theme-color($theme, primary); + } + `), + ).toThrowError(/Color information is not available on this theme/); + }); + + it('should error when reading typography from a theme with no typography information', () => { + expect(() => + transpile(` + $theme: matx.define-density(); + div { + font: mat.private-get-theme-typography($theme, body-small); + } + `), + ).toThrowError(/Typography information is not available on this theme/); + }); + + it('should error when reading density from a theme with no density information', () => { + expect(() => + transpile(` + $theme: matx.define-colors(); + div { + --density: #{mat.private-get-theme-density($theme)}; + } + `), + ).toThrowError(/Density information is not available on this theme/); + }); +}); diff --git a/src/material/core/theming/tests/theming-api.spec.ts b/src/material/core/theming/tests/theming-mixin-api.spec.ts similarity index 100% rename from src/material/core/theming/tests/theming-api.spec.ts rename to src/material/core/theming/tests/theming-mixin-api.spec.ts