diff --git a/src/material-experimental/theming/_config-validation.scss b/src/material-experimental/theming/_config-validation.scss index 45d2b08121bb..851034af47ca 100644 --- a/src/material-experimental/theming/_config-validation.scss +++ b/src/material-experimental/theming/_config-validation.scss @@ -1,6 +1,50 @@ @use 'sass:list'; @use 'sass:map'; +@use 'sass:meta'; +@use 'sass:string'; @use '@angular/material' as mat; +@use './m3-palettes'; + +/// Updates an error message by finding `$config` 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 _update-dollar-config($err, $suffix) { + @if meta.type-of($err) == 'list' { + @for $i from 1 through list.length($err) { + $err: list.set-nth($err, $i, _update-dollar-config(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 +52,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 _update-dollar-config($err, '.color'); } $err: validate-typography-config(map.get($config, typography)); @if $err { - @return list.set-nth($err, 1, #{'#{list.nth($err, 1)}.typography'}); + @return _update-dollar-config($err, '.typography'); } $err: validate-density-config(map.get($config, density)); @if $err { - @return list.set-nth($err, 1, #{'#{list.nth($err, 1)}.density'}); + @return _update-dollar-config($err, '.density'); } @return null; } @@ -35,12 +85,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 +124,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 +145,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 5c2036bb76b5..4119c5b1593a 100644 --- a/src/material-experimental/theming/_definition.scss +++ b/src/material-experimental/theming/_definition.scss @@ -69,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/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/tests/BUILD.bazel b/src/material/core/theming/tests/BUILD.bazel index aba4f88349e2..ca76dbe04dae 100644 --- a/src/material/core/theming/tests/BUILD.bazel +++ b/src/material/core/theming/tests/BUILD.bazel @@ -58,6 +58,7 @@ ts_library( name = "unit_test_lib", testonly = True, srcs = [ + "theming-definition-api.spec.ts", "theming-inspection-api.spec.ts", "theming-mixin-api.spec.ts", ], 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', + ]); + }); + }); +});