diff --git a/.ng-dev/google-sync-config.json b/.ng-dev/google-sync-config.json index a998f1cd12b2..04974862c102 100644 --- a/.ng-dev/google-sync-config.json +++ b/.ng-dev/google-sync-config.json @@ -6,7 +6,7 @@ "src/material/**/*.ts", "src/material/**/*.html", "src/material/**/*.scss", - "src/material/schematics/ng-generate/m3-theme/**/*", + "src/material/schematics/ng-generate/theme-color/**/*", "src/material-experimental/**/*.ts", "src/material-experimental/**/*.html", "src/material-experimental/**/*.scss", @@ -25,7 +25,7 @@ "src/**/*spec.ts", "src/cdk/schematics/**/*", "src/material/schematics/**/*", - "src/material/schematics/ng-generate/m3-theme/**/*.bazel", + "src/material/schematics/ng-generate/theme-color/**/*.bazel", "src/google-maps/schematics/**/*", "src/cdk/testing/testbed/zone-types.d.ts", "src/material/_theming.scss", diff --git a/guides/schematics.md b/guides/schematics.md index d028d2c4d8ab..ec7787de4429 100644 --- a/guides/schematics.md +++ b/guides/schematics.md @@ -118,11 +118,12 @@ ng generate @angular/cdk:drag-drop ### Material 3 Theme schematic -The `m3-theme` schematic will generate a file with Material 3 themes created -from custom colors. +The `theme-color` schematic will generate a file with Material 3 palettes from the specified colors +that can be used in a theme file. It also generates high contrast color override mixins if +specified. ``` -ng generate @angular/material:m3-theme +ng generate @angular/material:theme-color ``` -Learn more about this schematic in its [documentation](https://github.com/angular/components/blob/main/src/material/schematics/ng-generate/m3-theme/README.md). +Learn more about this schematic in its [documentation](https://github.com/angular/components/blob/main/src/material/schematics/ng-generate/theme-color/README.md). diff --git a/guides/theming.md b/guides/theming.md index 2a85feb7841e..e4c996420a02 100644 --- a/guides/theming.md +++ b/guides/theming.md @@ -128,30 +128,36 @@ and `tertiary` options: - `$rose-palette` ##### Custom theme -Alternatively, a theme can be generated with a custom color with the following schematic: +Alternatively, custom palettes can be generated with a custom color with the following schematic: ```shell -ng generate @angular/material:m3-theme +ng generate @angular/material:theme-color ``` -This schematic integrates with [Material Color Utilities](https://github.com/material-foundation/material-color-utilities) to build a theme based on a generated set of palettes based on a single color. Optionally you can provide additional custom colors for the secondary, tertiary, and neutral palettes. +This schematic integrates with [Material Color Utilities](https://github.com/material-foundation/material-color-utilities) to build palettes based on a single color. Optionally you can provide +additional custom colors for the secondary, tertiary, and neutral palettes. -The output of the schematic is a new Sass file that exports a theme or themes (if generating both a light and dark theme) that can be provided to component theme mixins. +The output of the schematic is a new Sass file that exports the palettes that can be provided to +a theme definition. ```scss @use '@angular/material' as mat; -@use './path/to/m3-theme'; - -@include mat.core(); +@use './path/to/my-theme'; // location of generated file html { - // Apply the light theme by default - @include mat.core-theme(m3-theme.$light-theme); - @include mat.button-theme(m3-theme.$light-theme); + @include mat.theme( + color: ( + primary: my-theme.$primary-palette, + tertiary: my-theme.$tertiary-palette, + ), + typography: Roboto, + density: 0, + ) } ``` -Learn more about this schematic in its [documentation](https://github.com/angular/components/blob/main/src/material/schematics/ng-generate/m3-theme/README.md). +You can also optionally generate high contrast override mixins for your custom theme that allows for +a better accessibility experience. Learn more about this schematic in its [documentation](https://github.com/angular/components/blob/main/src/material/schematics/ng-generate/theme-color/README.md). diff --git a/package.json b/package.json index 163af6a11a6c..bae8c99a8b66 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@bazel/terser": "5.8.1", "@bazel/worker": "5.8.1", "@firebase/app-types": "^0.7.0", - "@material/material-color-utilities": "^0.2.7", + "@material/material-color-utilities": "^0.3.0", "@octokit/rest": "18.3.5", "@rollup/plugin-commonjs": "^21.0.0", "@rollup/plugin-node-resolve": "^13.1.3", diff --git a/src/material/schematics/BUILD.bazel b/src/material/schematics/BUILD.bazel index 2f2b43b62d0d..67380d0ded2e 100644 --- a/src/material/schematics/BUILD.bazel +++ b/src/material/schematics/BUILD.bazel @@ -76,7 +76,7 @@ pkg_npm( name = "npm_package", srcs = ["package.json"], nested_packages = [ - "//src/material/schematics/ng-generate/m3-theme:npm_package", + "//src/material/schematics/ng-generate/theme-color:npm_package", ], deps = [ ":collection_assets", diff --git a/src/material/schematics/collection.json b/src/material/schematics/collection.json index dbe8df6925af..6a135e799ead 100644 --- a/src/material/schematics/collection.json +++ b/src/material/schematics/collection.json @@ -47,9 +47,9 @@ }, "m3Theme": { "description": "Generate M3 theme", - "factory": "./ng-generate/m3-theme/index_bundled", - "schema": "./ng-generate/m3-theme/schema.json", - "aliases": ["m3-theme", "M3-theme"] + "factory": "./ng-generate/theme-color/index_bundled", + "schema": "./ng-generate/theme-color/schema.json", + "aliases": ["theme-color"] } } } diff --git a/src/material/schematics/ng-generate/m3-theme/README.md b/src/material/schematics/ng-generate/m3-theme/README.md deleted file mode 100644 index 434bc69d96a6..000000000000 --- a/src/material/schematics/ng-generate/m3-theme/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Material 3 Custom Theme schematic - -```shell -ng generate @angular/material:m3-theme -``` - -This schematic allows users to create new Material 3 theme configurations based -on custom colors by using [Material Color Utilities](https://github.com/material-foundation/material-color-utilities). - -The generated [color palettes](https://m3.material.io/styles/color/roles) are -optimized to have enough contrast to be more accessible. See [Science of Color Design](https://material.io/blog/science-of-color-design) for more information about Material's color design. - -For more customization, custom palette colors can be also be provided for the -secondary, tertiary, and neutral colors. It is recommended to choose colors that -are contrastful, Material has more detailed guidance for [accessible design](https://m3.material.io/foundations/accessible-design/patterns). - -The output of the schematic will create a file named `m3-theme.scss` at the -specified directory or the project root with the generated themes. The exported -themes (`$light-theme` and/or `$dark-theme`) can be provided to component theme -mixins. - -If you're using the system variables option, you should remember to either provide values for the -system variables (all prefixed with `--sys-`), or to include the `system-level-colors` and -`system-level-typography` mixins which will generate the values based on your theme. - -The default prefix for system variables is `--sys-`. This prefix can be customized. For -example, to change the prefix to `--md-sys-`, use the following configuration at the color or typography level: -`system-prefix: md-sys`. - -```scss -@use '@angular/material' as mat; -@use './path/to/my-theme'; - -@include mat.core(); - -html { - // Apply the light theme by default - @include mat.core-theme(my-theme.$light-theme); - @include mat.button-theme(my-theme.$light-theme); - - // When using system variables, remember to provide values for them - // or uncomment the lines below to generate them from the theme. - // @include mat.system-level-colors(my-theme.$light-theme); - // @include mat.system-level-typography(my-theme.$light-theme); -} -``` - -## Options - -### Required - -* `primaryColor` - Color to use for app's primary color palette (Note: the other -palettes described in the Material 3 spec will be automatically chosen based on -your primary palette unless specified, to ensure a harmonious color combination). - -### Optional - -* `secondaryColor` - Color to use for app's secondary color palette. Defaults to -secondary color generated from Material based on the primary. -* `tertiaryColor` - Color to use for app's tertiary color palette. Defaults to -tertiary color generated from Material based on the primary. -* `neutralColor` - Color to use for app's neutral color palette. Defaults to -neutral color generated from Material based on the primary. -* `directory` - Relative path to a directory within the project that the -generated theme file should be created in. Defaults to the project root. -* `themeTypes` - Theme types ('light', 'dark', or 'both') to generate themes for. Defaults to both. -* `useSystemVariables` - Whether to generate a theme that uses system-level variables for easier -dynamic theming. Defaults to false. diff --git a/src/material/schematics/ng-generate/m3-theme/index.ts b/src/material/schematics/ng-generate/m3-theme/index.ts deleted file mode 100644 index 834929b673d5..000000000000 --- a/src/material/schematics/ng-generate/m3-theme/index.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics'; -import {Schema} from './schema'; -import { - argbFromHex, - hexFromArgb, - TonalPalette, - Hct, - SchemeContent, -} from '@material/material-color-utilities'; - -// For each color tonal palettes are created using the following hue tones. The -// tonal palettes then get used to create the different color roles (ex. -// on-primary) https://m3.material.io/styles/color/system/how-the-system-works -const HUE_TONES = [0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; -// Map of neutral hues to the previous/next hues that -// can be used to estimate them, in case they're missing. -const NEUTRAL_HUES = new Map([ - [4, {prev: 0, next: 10}], - [6, {prev: 0, next: 10}], - [12, {prev: 10, next: 20}], - [17, {prev: 10, next: 20}], - [22, {prev: 20, next: 25}], - [24, {prev: 20, next: 25}], - [87, {prev: 80, next: 90}], - [92, {prev: 90, next: 95}], - [94, {prev: 90, next: 95}], - [96, {prev: 95, next: 98}], -]); - -// Note: Some of the color tokens refer to additional hue tones, but this only -// applies for the neutral color palette (ex. surface container is neutral -// palette's 94 tone). https://m3.material.io/styles/color/static/baseline -const NEUTRAL_HUE_TONES = [...HUE_TONES, ...NEUTRAL_HUES.keys()]; - -/** - * Gets color tonal palettes generated by Material from the provided color. - * @param color Color that represent primary to generate all the tonal palettes. - * @returns Object with tonal palettes for each color - */ -function getMaterialTonalPalettes(color: string): { - primary: TonalPalette; - secondary: TonalPalette; - tertiary: TonalPalette; - neutral: TonalPalette; - neutralVariant: TonalPalette; - error: TonalPalette; -} { - try { - let argbColor = argbFromHex(color); - const scheme = new SchemeContent( - Hct.fromInt(argbColor), - false, // Tonal palettes are the same for light and dark themes - 0.0, - ); - - return { - primary: scheme.primaryPalette, - secondary: scheme.secondaryPalette, - tertiary: scheme.tertiaryPalette, - neutral: scheme.neutralPalette, - neutralVariant: scheme.neutralVariantPalette, - error: scheme.errorPalette, - }; - } catch (e) { - throw new Error( - 'Cannot parse the specified color ' + - color + - '. Please verify it is a hex color (ex. #ffffff or ffffff).', - ); - } -} - -/** - * Gets map of all the color tonal palettes from a specified color. - * @param color Color that represent primary to generate the color tonal palettes. - * @returns Map with the colors and their hue tones and values. - */ -function getColorTonalPalettes(color: string): Map> { - const tonalPalettes = getMaterialTonalPalettes(color); - const palettes: Map> = new Map(); - for (const [key, palette] of Object.entries(tonalPalettes)) { - const paletteKey = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); - const tones = paletteKey === 'neutral' ? NEUTRAL_HUE_TONES : HUE_TONES; - const colorPalette: Map = new Map(); - for (const tone of tones) { - const color = hexFromArgb(palette.tone(tone)); - colorPalette.set(tone, color); - } - palettes.set(paletteKey, colorPalette); - } - return palettes; -} - -/** - * Gets the scss representation of the provided color palettes. - * @param colorPalettes Map of colors and their hue tones and values. - * @returns String of the color palettes scss. - */ -function getColorPalettesSCSS(colorPalettes: Map>): string { - let scss = '(\n'; - for (const [variant, palette] of colorPalettes!.entries()) { - scss += ' ' + variant + ': (\n'; - for (const [key, value] of palette.entries()) { - scss += ' ' + key + ': ' + value + ',\n'; - } - scss += ' ),\n'; - } - scss += ');'; - return scss; -} - -/** - * Gets the generated scss from the provided color palettes and theme types. - * @param colorPalettes Map of colors and their hue tones and values. - * @param themeTypes Theme types for the theme (ex. 'light', 'dark', or 'both'). - * @param colorComment Comment with original hex colors used to generate palettes. - * @param useSystemVariables Whether to use system-level variables in the generated theme. - * @returns String of the generated theme scss. - */ -export function generateSCSSTheme( - colorPalettes: Map>, - themeTypes: string, - colorComment: string, - useSystemVariables: boolean, -): string { - let scss = [ - "// This file was generated by running 'ng generate @angular/material:m3-theme'.", - '// Proceed with caution if making changes to this file.', - '', - "@use 'sass:map';", - "@use '@angular/material' as mat;", - '', - '// Note: ' + colorComment, - '$_palettes: ' + getColorPalettesSCSS(patchMissingHues(colorPalettes)), - '', - '$_rest: (', - ' secondary: map.get($_palettes, secondary),', - ' neutral: map.get($_palettes, neutral),', - ' neutral-variant: map.get($_palettes, neutral-variant),', - ' error: map.get($_palettes, error),', - ');', - '$_primary: map.merge(map.get($_palettes, primary), $_rest);', - '$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest);', - '', - ]; - - let themes = themeTypes === 'both' ? ['light', 'dark'] : [themeTypes]; - // Note: Call define-theme function here since creating the color tokens - // from the palettes is a private function - for (const themeType of themes) { - scss = scss.concat([ - '$' + themeType + '-theme: mat.define-theme((', - ' color: (', - ' theme-type: ' + themeType + ',', - ' primary: $_primary,', - ' tertiary: $_tertiary,', - ...(useSystemVariables ? [' use-system-variables: true,'] : []), - ' ),', - ...(useSystemVariables ? [' typography: (', ' use-system-variables: true,', ' ),'] : []), - '));', - ]); - } - return scss.join('\n'); -} - -/** - * Creates theme file for provided scss. - * @param scss scss for the theme file. - * @param tree Directory tree. - * @param directory Directory path to place generated theme file. - */ -function createThemeFile(scss: string, tree: Tree, directory?: string) { - const filePath = directory ? directory + 'm3-theme.scss' : 'm3-theme.scss'; - tree.create(filePath, scss); -} - -export default function (options: Schema): Rule { - return async (tree: Tree, context: SchematicContext) => { - const colorPalettes = getColorTonalPalettes(options.primaryColor); - let colorComment = 'Color palettes are generated from primary: ' + options.primaryColor; - - if (options.secondaryColor) { - colorPalettes.set('secondary', getColorTonalPalettes(options.secondaryColor).get('primary')!); - colorComment += ', secondary: ' + options.secondaryColor; - } - if (options.tertiaryColor) { - colorPalettes.set('tertiary', getColorTonalPalettes(options.tertiaryColor).get('primary')!); - colorComment += ', tertiary: ' + options.tertiaryColor; - } - if (options.neutralColor) { - colorPalettes.set('neutral', getColorTonalPalettes(options.neutralColor).get('primary')!); - colorComment += ', neutral: ' + options.neutralColor; - } - - if (!options.themeTypes) { - context.logger.info('No theme types specified, creating both light and dark themes.'); - options.themeTypes = 'both'; - } - - const themeScss = generateSCSSTheme( - colorPalettes, - options.themeTypes, - colorComment, - options.useSystemVariables || false, - ); - createThemeFile(themeScss, tree, options.directory); - }; -} - -/** - * The hue map produced by `material-color-utilities` may miss some neutral hues depending on - * the provided colors. This function estimates the missing hues based on the generated ones - * to ensure that we always produce a full palette. See #29157. - * - * This is a TypeScript port of the logic in `core/theming/_palettes.scss#_patch-missing-hues`. - */ -function patchMissingHues( - palettes: Map>, -): Map> { - const neutral = palettes.get('neutral'); - - if (!neutral) { - return palettes; - } - - let newNeutral: Map | null = null; - - for (const [hue, {prev, next}] of NEUTRAL_HUES) { - if (!neutral.has(hue) && neutral.has(prev) && neutral.has(next)) { - const weight = (next - hue) / (next - prev); - const result = mixColors(neutral.get(prev)!, neutral.get(next)!, weight); - - if (result !== null) { - newNeutral ??= new Map(neutral.entries()); - newNeutral.set(hue, result); - } - } - } - - if (!newNeutral) { - return palettes; - } - - // Create a new map so we don't mutate the one that was passed in. - const newPalettes = new Map>(); - for (const [key, value] of palettes) { - if (key === 'neutral') { - // Maps keep the order of their keys which can make the newly-added - // ones look out of place. Re-sort the the keys in ascending order. - const sortedNeutral = Array.from(newNeutral.keys()) - .sort((a, b) => a - b) - .reduce((newHues, key) => { - newHues.set(key, newNeutral.get(key)!); - return newHues; - }, new Map()); - newPalettes.set(key, sortedNeutral); - } else { - newPalettes.set(key, value); - } - } - - return newPalettes; -} - -/** - * TypeScript port of the `color.mix` function from Sass, simplified to only deal with hex colors. - * See https://github.com/sass/dart-sass/blob/main/lib/src/functions/color.dart#L803 - * - * @param c1 First color to use in the mixture. - * @param c2 Second color to use in the mixture. - * @param weight Proportion of the first color to use in the mixture. - * Should be a number between 0 and 1. - */ -function mixColors(c1: string, c2: string, weight: number): string | null { - const normalizedWeight = weight * 2 - 1; - const weight1 = (normalizedWeight + 1) / 2; - const weight2 = 1 - weight1; - const color1 = parseHexColor(c1); - const color2 = parseHexColor(c2); - - if (color1 === null || color2 === null) { - return null; - } - - const red = Math.round(color1.red * weight1 + color2.red * weight2); - const green = Math.round(color1.green * weight1 + color2.green * weight2); - const blue = Math.round(color1.blue * weight1 + color2.blue * weight2); - const intToHex = (value: number) => value.toString(16).padStart(2, '0'); - - return `#${intToHex(red)}${intToHex(green)}${intToHex(blue)}`; -} - -/** Parses a hex color to its numeric red, green and blue values. */ -function parseHexColor(value: string): {red: number; green: number; blue: number} | null { - if (!/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) { - return null; - } - - const hexToInt = (value: string) => parseInt(value, 16); - let red: number; - let green: number; - let blue: number; - - if (value.length === 4) { - red = hexToInt(value[1] + value[1]); - green = hexToInt(value[2] + value[2]); - blue = hexToInt(value[3] + value[3]); - } else { - red = hexToInt(value.slice(1, 3)); - green = hexToInt(value.slice(3, 5)); - blue = hexToInt(value.slice(5, 7)); - } - - return {red, green, blue}; -} diff --git a/src/material/schematics/ng-generate/m3-theme/BUILD.bazel b/src/material/schematics/ng-generate/theme-color/BUILD.bazel similarity index 100% rename from src/material/schematics/ng-generate/m3-theme/BUILD.bazel rename to src/material/schematics/ng-generate/theme-color/BUILD.bazel diff --git a/src/material/schematics/ng-generate/theme-color/README.md b/src/material/schematics/ng-generate/theme-color/README.md new file mode 100644 index 000000000000..c736a818e225 --- /dev/null +++ b/src/material/schematics/ng-generate/theme-color/README.md @@ -0,0 +1,100 @@ +# Material 3 Custom Theme schematic + +```shell +ng generate @angular/material:theme-color +``` + +This schematic allows users to create new Material 3 theme palettes based +on custom colors by using [Material Color Utilities](https://github.com/material-foundation/material-color-utilities). + +The generated [color palettes](https://m3.material.io/styles/color/roles) are +optimized to have enough contrast to be more accessible. See [Science of Color Design](https://material.io/blog/science-of-color-design) for more information about Material's color design. + +For more customization, custom colors can be also be provided for the +secondary, tertiary, and neutral palette colors. It is recommended to choose colors that +are contrastful, Material has more detailed guidance for [accessible design](https://m3.material.io/foundations/accessible-design/patterns). + +The output of the schematic will create a file named `_theme-colors.scss` at the +specified directory or the project root with the generated palettes. The exported +palettes (`$primary-palette` and `$tertiary-palette`) can be provided to the `theme` mixin within your theme file to use the custom colors. + +```scss +@use '@angular/material' as mat; +@use './path/to/my-theme'; // location of generated file + +html { + @include mat.theme( + color: ( + primary: my-theme.$primary-palette, + tertiary: my-theme.$tertiary-palette, + ), + typography: Roboto, + density: 0, + ) +} +``` + +High contrast override theme mixins are also generated in the file if specified +(`high-contrast-light-theme-overrides` and `high-contrast-dark-theme-overrides`). These mixins +override the system level variables with high contrast equivalent values from your theme. This is +helpful for users who prefer more contrastful colors for either preference or accessibility reasons. + +```scss +@use '@angular/material'; +@use './path/to/my-theme'; // location of generated file + +html { + // Apply the light theme by default + @include material.theme(( + color: ( + primary: my-theme.$primary-palette, + tertiary: my-theme.$tertiary-palette, + ), + typography: Roboto, + density: 0, + )); + + // Use high contrast light theme colors when users prefer contrast + @media (prefers-contrast: more) { + @include my-theme.high-contrast-light-theme-overrides(); + } + + // Apply dark theme when users prefer a dark color scheme + @media (prefers-color-scheme: dark) { + @include material.theme(( + color: ( + primary: my-theme.$primary-palette, + tertiary: my-theme.$tertiary-palette, + theme-type: dark, + ), + )); + + // Use high contrast dark theme colors when users prefers a dark color scheme and contrast + @media (prefers-contrast: more) { + @include my-theme.high-contrast-dark-theme-overrides(); + } + } +} +``` + +## Options + +### Required + +* `primaryColor` - Color to use for app's primary color palette (Note: the other +palettes described in the Material 3 spec will be automatically chosen based on +your primary palette unless specified, to ensure a harmonious color combination). + +### Optional + +* `secondaryColor` - Color to use for app's secondary color palette. Defaults to +secondary color generated from Material based on the primary. +* `tertiaryColor` - Color to use for app's tertiary color palette. Defaults to +tertiary color generated from Material based on the primary. +* `neutralColor` - Color to use for app's neutral color palette. Defaults to +neutral color generated from Material based on the primary. +* `includeHighContrast` - Whether to add high contrast override mixins to generated +theme file. Developers can call the mixin when they want to show a high contrast version of their +theme. Defaults to false. +* `directory` - Relative path to a directory within the project that the +generated theme file should be created in. Defaults to the project root. diff --git a/src/material/schematics/ng-generate/m3-theme/index.spec.ts b/src/material/schematics/ng-generate/theme-color/index.spec.ts similarity index 62% rename from src/material/schematics/ng-generate/m3-theme/index.spec.ts rename to src/material/schematics/ng-generate/theme-color/index.spec.ts index 8d5e7da39cab..498b387968f8 100644 --- a/src/material/schematics/ng-generate/m3-theme/index.spec.ts +++ b/src/material/schematics/ng-generate/theme-color/index.spec.ts @@ -10,11 +10,11 @@ import {Schema} from './schema'; // Note: For Windows compatibility, we need to resolve the directory paths through runfiles // which are guaranteed to reside in the source tree. -const testDir = runfiles.resolvePackageRelative('../m3-theme'); +const testDir = runfiles.resolvePackageRelative('../theme-color'); const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); -describe('material-m3-theme-schematic', () => { +describe('material-theme-color-schematic', () => { let runner: SchematicTestRunner; let testM3ThemePalette: Map>; @@ -24,18 +24,35 @@ describe('material-m3-theme-schematic', () => { ` ${content} - @mixin _theme($theme) { - @include mat.all-component-colors($theme); - @include mat.system-level-colors($theme); - @include mat.system-level-typography($theme); - } - html { - @if variable-exists(light-theme) { - @include _theme($light-theme); + @include mat.theme(( + color: ( + primary: $primary-palette, + tertiary: $tertiary-palette, + theme-type: light, + ), + )); + + @if mixin-exists(high-contrast-light-theme-overrides) { + & { + @include high-contrast-light-theme-overrides(); + } } - @if variable-exists(dark-theme) { - @include _theme($dark-theme); + + &.dark-theme { + @include mat.theme(( + color: ( + primary: $primary-palette, + tertiary: $tertiary-palette, + theme-type: dark, + ), + )); + + @if mixin-exists(high-contrast-dark-theme-overrides) { + & { + @include high-contrast-dark-theme-overrides(); + } + } } } `, @@ -51,7 +68,7 @@ describe('material-m3-theme-schematic', () => { options: Schema, ): Promise { const app = await createTestApp(runner, {standalone: true}); - return runner.runSchematic('m3-theme', options, app); + return runner.runSchematic('theme-color', options, app); } beforeEach(() => { @@ -66,7 +83,6 @@ describe('material-m3-theme-schematic', () => { try { await runM3ThemeSchematic(runner, { primaryColor: '#fffff', - themeTypes: 'light', }); } catch (e) { expect((e as Error).message).toBe( @@ -78,75 +94,34 @@ describe('material-m3-theme-schematic', () => { it('should generate m3 theme file', async () => { const tree = await runM3ThemeSchematic(runner, { primaryColor: '#984061', - themeTypes: 'light', }); - expect(tree.exists('m3-theme.scss')).toBe(true); + expect(tree.exists('_theme-colors.scss')).toBe(true); }); it('should generate m3 theme file at specified path', async () => { const tree = await runM3ThemeSchematic(runner, { primaryColor: '#984061', - themeTypes: 'light', directory: 'projects/', }); - expect(tree.exists('projects/m3-theme.scss')).toBe(true); + expect(tree.exists('projects/_theme-colors.scss')).toBe(true); }); it('should generate m3 theme file with correct indentation and formatting', async () => { const tree = await runM3ThemeSchematic(runner, { primaryColor: '#984061', - themeTypes: 'both', }); - expect(tree.readText('m3-theme.scss')).toEqual(getTestTheme()); + expect(tree.readText('_theme-colors.scss')).toEqual(getTestTheme()); }); - it('should generate light theme when provided a primary color', async () => { + it('should generate themes when provided a primary color', async () => { const tree = await runM3ThemeSchematic(runner, { primaryColor: '#984061', - themeTypes: 'light', }); - const generatedSCSS = tree.readText('m3-theme.scss'); + const generatedSCSS = tree.readText('_theme-colors.scss'); const testSCSS = generateSCSSTheme( testM3ThemePalette, - 'light', 'Color palettes are generated from primary: #984061', - false, - ); - - expect(generatedSCSS).toBe(testSCSS); - }); - - it('should generate dark theme when provided a primary color', async () => { - const tree = await runM3ThemeSchematic(runner, { - primaryColor: '#984061', - themeTypes: 'dark', - }); - - const generatedSCSS = tree.readText('m3-theme.scss'); - const testSCSS = generateSCSSTheme( - testM3ThemePalette, - 'dark', - 'Color palettes are generated from primary: #984061', - false, - ); - - expect(generatedSCSS).toBe(testSCSS); - expect(transpileTheme(generatedSCSS)).toBe(transpileTheme(testSCSS)); - }); - - it('should generate light and dark theme when provided a primary color', async () => { - const tree = await runM3ThemeSchematic(runner, { - primaryColor: '#984061', - themeTypes: 'both', - }); - - const generatedSCSS = tree.readText('m3-theme.scss'); - const testSCSS = generateSCSSTheme( - testM3ThemePalette, - 'both', - 'Color palettes are generated from primary: #984061', - false, ); expect(generatedSCSS).toBe(testSCSS); @@ -157,10 +132,9 @@ describe('material-m3-theme-schematic', () => { const tree = await runM3ThemeSchematic(runner, { primaryColor: '#984061', secondaryColor: '#984061', - themeTypes: 'both', }); - const generatedSCSS = tree.readText('m3-theme.scss'); + const generatedSCSS = tree.readText('_theme-colors.scss'); // Change test theme palette so that secondary is the same source color as // primary to match schematic inputs @@ -169,9 +143,7 @@ describe('material-m3-theme-schematic', () => { const testSCSS = generateSCSSTheme( testPalette, - 'both', 'Color palettes are generated from primary: #984061, secondary: #984061', - false, ); expect(generatedSCSS).toBe(testSCSS); @@ -183,10 +155,9 @@ describe('material-m3-theme-schematic', () => { primaryColor: '#984061', secondaryColor: '#984061', tertiaryColor: '#984061', - themeTypes: 'both', }); - const generatedSCSS = tree.readText('m3-theme.scss'); + const generatedSCSS = tree.readText('_theme-colors.scss'); // Change test theme palette so that secondary and tertiary are the same // source color as primary to match schematic inputs @@ -196,9 +167,7 @@ describe('material-m3-theme-schematic', () => { const testSCSS = generateSCSSTheme( testPalette, - 'both', 'Color palettes are generated from primary: #984061, secondary: #984061, tertiary: #984061', - false, ); expect(generatedSCSS).toBe(testSCSS); @@ -211,104 +180,142 @@ describe('material-m3-theme-schematic', () => { secondaryColor: '#984061', tertiaryColor: '#984061', neutralColor: '#984061', - themeTypes: 'both', }); - const generatedSCSS = tree.readText('m3-theme.scss'); + const generatedSCSS = tree.readText('_theme-colors.scss'); // Change test theme palette so that secondary, tertiary, and neutral are // the same source color as primary to match schematic inputs let testPalette = testM3ThemePalette; testPalette.set('secondary', testM3ThemePalette.get('primary')!); testPalette.set('tertiary', testM3ThemePalette.get('primary')!); - testPalette.set('neutral', testM3ThemePalette.get('primary')!); + + // Neutral's tonal palette has additional tones as opposed to the other color palettes. + let neutralPalette = new Map(testM3ThemePalette.get('primary')!); + neutralPalette.set(4, '#26000f'); + neutralPalette.set(6, '#2f0015'); + neutralPalette.set(12, '#460022'); + neutralPalette.set(17, '#55082c'); + neutralPalette.set(22, '#631637'); + neutralPalette.set(24, '#691a3c'); + neutralPalette.set(87, '#ffcdda'); + neutralPalette.set(92, '#ffe1e8'); + neutralPalette.set(94, '#ffe8ed'); + neutralPalette.set(96, '#fff0f2'); + testPalette.set('neutral', neutralPalette); const testSCSS = generateSCSSTheme( testPalette, - 'both', 'Color palettes are generated from primary: #984061, secondary: #984061, tertiary: #984061, neutral: #984061', - false, ); expect(generatedSCSS).toBe(testSCSS); expect(transpileTheme(generatedSCSS)).toBe(transpileTheme(testSCSS)); }); - it('should be able to generate a theme using system variables', async () => { - const primaryColor = '#984061'; + it('should be able to generate high contrast theme mixins', async () => { const tree = await runM3ThemeSchematic(runner, { - primaryColor, - themeTypes: 'light', - useSystemVariables: true, + primaryColor: '#984061', + includeHighContrast: true, }); - const generatedSCSS = tree.readText('m3-theme.scss'); - const generatedCSS = transpileTheme(generatedSCSS); - - expect(generatedSCSS).toContain( - [ - ` color: (`, - ` theme-type: light,`, - ` primary: $_primary,`, - ` tertiary: $_tertiary,`, - ` use-system-variables: true,`, - ` ),`, - ` typography: (`, - ` use-system-variables: true,`, - ` ),`, - ].join('\n'), - ); + const generatedSCSS = tree.readText('_theme-colors.scss'); - expect(generatedCSS).toContain(`--sys-primary: ${primaryColor}`); - expect(generatedCSS).toContain('var(--sys-primary)'); + expect(generatedSCSS).toContain(`@mixin high-contrast-light-theme-overrides`); + expect(generatedSCSS).toContain(`@mixin high-contrast-dark-theme-overrides`); }); - it('should estimate missing neutral hues', async () => { + it('should be able to generate high contrast themes overrides when provided a primary color', async () => { const tree = await runM3ThemeSchematic(runner, { - primaryColor: '#232e62', - secondaryColor: '#cc862a', - tertiaryColor: '#44263e', - neutralColor: '#929093', - themeTypes: 'light', + primaryColor: '#984061', + includeHighContrast: true, }); - expect(tree.readContent('m3-theme.scss')).toContain( - [ - ` neutral: (`, - ` 0: #000000,`, - ` 4: #0b0b0c,`, - ` 6: #111012,`, - ` 10: #1c1b1e,`, - ` 12: #201f22,`, - ` 17: #2b2a2d,`, - ` 20: #313033,`, - ` 22: #353437,`, - ` 24: #3a393c,`, - ` 25: #3c3b3e,`, - ` 30: #474649,`, - ` 35: #535255,`, - ` 40: #5f5e61,`, - ` 50: #787679,`, - ` 60: #929093,`, - ` 70: #adaaad,`, - ` 80: #c8c5c9,`, - ` 87: #dcd9dd,`, - ` 90: #e5e1e5,`, - ` 92: #ebe7eb,`, - ` 94: #f0edf0,`, - ` 95: #f3f0f3,`, - ` 96: #f6f3f6,`, - ` 98: #fcf8fb,`, - ` 99: #fffbfe,`, - ` 100: #ffffff,`, - ` ),`, - ].join('\n'), - ); + const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss')); + + // Check a system variable from each color palette for their high contrast light theme value + expect(generatedCSS).toContain(`--mat-app-primary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-secondary: #45212d`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #4d1f00`); + expect(generatedCSS).toContain(`--mat-app-error: #600004`); + expect(generatedCSS).toContain(`--mat-app-surface: #fff8f8`); + expect(generatedCSS).toContain(`--mat-app-outline: #37282c`); + + // Check a system variable from each color palette for their high contrast dark theme value + expect(generatedCSS).toContain(`--mat-app-primary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-secondary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #ffece4`); + expect(generatedCSS).toContain(`--mat-app-error: #ffece9`); + expect(generatedCSS).toContain(`--mat-app-surface: #191113`); + expect(generatedCSS).toContain(`--mat-app-outline: #ffebef`); + }); + + it('should be able to generate high contrast themes overrides when provided a primary and secondary color', async () => { + const tree = await runM3ThemeSchematic(runner, { + primaryColor: '#984061', + secondaryColor: '#984061', + includeHighContrast: true, + }); + + const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss')); + + // Check a system variable from each color palette for their high contrast light theme value + expect(generatedCSS).toContain(`--mat-app-primary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-secondary: #580b2f`); + + // Check a system variable from each color palette for their high contrast dark theme value + expect(generatedCSS).toContain(`--mat-app-primary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-secondary: #ffebef`); + }); + + it('should be able to generate high contrast themes overrides when provided primary, secondary, and tertiary color', async () => { + const tree = await runM3ThemeSchematic(runner, { + primaryColor: '#984061', + secondaryColor: '#984061', + tertiaryColor: '#984061', + includeHighContrast: true, + }); + + const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss')); + + // Check a system variable from each color palette for their high contrast light theme value + expect(generatedCSS).toContain(`--mat-app-primary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-secondary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #580b2f`); + + // Check a system variable from each color palette for their high contrast dark theme value + expect(generatedCSS).toContain(`--mat-app-primary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-secondary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #ffebef`); + }); + + it('should be able to generate high contrast themes overrides when provided primary, secondary, tertiary, and neutral color', async () => { + const tree = await runM3ThemeSchematic(runner, { + primaryColor: '#984061', + secondaryColor: '#984061', + tertiaryColor: '#984061', + neutralColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette + includeHighContrast: true, + }); + + const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss')); + + // Check a system variable from each color palette for their high contrast light theme value + expect(generatedCSS).toContain(`--mat-app-primary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-secondary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #580b2f`); + expect(generatedCSS).toContain(`--mat-app-surface-bright: #f9f9f9`); + + // Check a system variable from each color palette for their high contrast dark theme value + expect(generatedCSS).toContain(`--mat-app-primary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-secondary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-tertiary: #ffebef`); + expect(generatedCSS).toContain(`--mat-app-surface-bright: #4f5051`); }); }); function getTestTheme() { - return `// This file was generated by running 'ng generate @angular/material:m3-theme'. + return `// This file was generated by running 'ng generate @angular/material:theme-color'. // Proceed with caution if making changes to this file. @use 'sass:map'; @@ -442,23 +449,9 @@ $_rest: ( neutral-variant: map.get($_palettes, neutral-variant), error: map.get($_palettes, error), ); -$_primary: map.merge(map.get($_palettes, primary), $_rest); -$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); - -$light-theme: mat.define-theme(( - color: ( - theme-type: light, - primary: $_primary, - tertiary: $_tertiary, - ), -)); -$dark-theme: mat.define-theme(( - color: ( - theme-type: dark, - primary: $_primary, - tertiary: $_tertiary, - ), -));`; + +$primary-palette: map.merge(map.get($_palettes, primary), $_rest); +$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);`; } function getPaletteMap() { diff --git a/src/material/schematics/ng-generate/theme-color/index.ts b/src/material/schematics/ng-generate/theme-color/index.ts new file mode 100644 index 000000000000..97fbbde79235 --- /dev/null +++ b/src/material/schematics/ng-generate/theme-color/index.ts @@ -0,0 +1,416 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics'; +import {Schema} from './schema'; +import { + argbFromHex, + hexFromArgb, + TonalPalette, + Hct, + DynamicScheme, + DislikeAnalyzer, + TemperatureCache, +} from '@material/material-color-utilities'; + +// For each color tonal palettes are created using the following hue tones. The +// tonal palettes then get used to create the different color roles (ex. +// on-primary) https://m3.material.io/styles/color/system/how-the-system-works +const HUE_TONES = [0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; +// Map of neutral hues to the previous/next hues that +// can be used to estimate them, in case they're missing. +const NEUTRAL_HUES = new Map([ + [4, {prev: 0, next: 10}], + [6, {prev: 0, next: 10}], + [12, {prev: 10, next: 20}], + [17, {prev: 10, next: 20}], + [22, {prev: 20, next: 25}], + [24, {prev: 20, next: 25}], + [87, {prev: 80, next: 90}], + [92, {prev: 90, next: 95}], + [94, {prev: 90, next: 95}], + [96, {prev: 95, next: 98}], +]); + +// Note: Some of the color tokens refer to additional hue tones, but this only +// applies for the neutral color palette (ex. surface container is neutral +// palette's 94 tone). https://m3.material.io/styles/color/static/baseline +const NEUTRAL_HUE_TONES = [...HUE_TONES, ...NEUTRAL_HUES.keys()]; + +/** + * Gets color tonal palettes generated by Material from the provided color. + * @param primaryPalette Tonal palette that represents primary. + * @param secondaryPalette Tonal palette that represents secondary. + * @param tertiaryPalette Tonal palette that represents tertiary. + * @param neutralPalette Tonal palette that represents neutral. + * @param neutralVariantPalette Tonal palette that represents neutral variant. + * @param isDark Boolean to represent if the scheme is for a dark or light theme. + * @param contrastLevel Number between -1 and 1 for the contrast level. 0 is the standard contrast + * and 1 represents high contrast. + * @returns Dynamic scheme for provided theme and contrast level + */ +function getMaterialDynamicScheme( + primaryPalette: TonalPalette, + secondaryPalette: TonalPalette, + tertiaryPalette: TonalPalette, + neutralPalette: TonalPalette, + neutralVariantPalette: TonalPalette, + isDark: boolean, + contrastLevel: number, +): DynamicScheme { + return new DynamicScheme({ + sourceColorArgb: primaryPalette.keyColor.toInt(), + variant: 6, // Variant.FIDELITY, used number representation since enum is not accessible outside of @material/material-color-utilities + contrastLevel: contrastLevel, + isDark: isDark, + primaryPalette: primaryPalette, + secondaryPalette: secondaryPalette, + tertiaryPalette: tertiaryPalette, + neutralPalette: neutralPalette, + neutralVariantPalette: neutralVariantPalette, + }); +} + +/** + * Gets the scss representation of the provided color palettes. + * @param colorPalettes Map of colors and their hue tones and values. + * @returns String of the color palettes scss. + */ +function getColorPalettesSCSS(colorPalettes: Map>): string { + let scss = '(\n'; + for (const [variant, palette] of colorPalettes!.entries()) { + scss += ' ' + variant + ': (\n'; + for (const [key, value] of palette.entries()) { + scss += ' ' + key + ': ' + value + ',\n'; + } + scss += ' ),\n'; + } + scss += ');'; + return scss; +} + +/** + * Gets map of all the color tonal palettes with their tones and colors from provided palettes. + * @param primaryPalette Tonal palette that represents primary. + * @param secondaryPalette Tonal palette that represents secondary. + * @param tertiaryPalette Tonal palette that represents tertiary. + * @param neutralPalette Tonal palette that represents neutral. + * @param neutralVariantPalette Tonal palette that represents neutral variant. + * @param errorPalette Tonal palette that represents error. + * @returns Map with the colors and their hue tones and values. + */ +function getMapFromColorTonalPalettes( + primaryPalette: TonalPalette, + secondaryPalette: TonalPalette, + tertiaryPalette: TonalPalette, + neutralPalette: TonalPalette, + neutralVariantPalette: TonalPalette, + errorPalette: TonalPalette, +) { + const tonalPalettes = { + primary: primaryPalette, + secondary: secondaryPalette, + tertiary: tertiaryPalette, + neutral: neutralPalette, + neutralVariant: neutralVariantPalette, + error: errorPalette, + }; + const palettes: Map> = new Map(); + for (const [key, palette] of Object.entries(tonalPalettes)) { + const paletteKey = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + const tones = paletteKey === 'neutral' ? NEUTRAL_HUE_TONES : HUE_TONES; + const colorPalette: Map = new Map(); + for (const tone of tones) { + const color = hexFromArgb(palette.tone(tone)); + colorPalette.set(tone, color); + } + palettes.set(paletteKey, colorPalette); + } + return palettes; +} + +/** + * Gets the generated scss from the provided color palettes and theme types. + * @param colorPalettes Map of colors and their hue tones and values. + * @param colorComment Comment with original hex colors used to generate palettes. + * @returns String of the generated theme scss. + */ +export function generateSCSSTheme( + colorPalettes: Map>, + colorComment: string, +): string { + let scss = [ + "// This file was generated by running 'ng generate @angular/material:theme-color'.", + '// Proceed with caution if making changes to this file.', + '', + "@use 'sass:map';", + "@use '@angular/material' as mat;", + '', + '// Note: ' + colorComment, + '$_palettes: ' + getColorPalettesSCSS(colorPalettes), + '', + '$_rest: (', + ' secondary: map.get($_palettes, secondary),', + ' neutral: map.get($_palettes, neutral),', + ' neutral-variant: map.get($_palettes, neutral-variant),', + ' error: map.get($_palettes, error),', + ');', + '', + '$primary-palette: map.merge(map.get($_palettes, primary), $_rest);', + '$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);', + ]; + + return scss.join('\n'); +} + +/** + * Gets map of system variables and their high contrast values. + * @param primaryPalette Tonal palette that represents primary. + * @param secondaryPalette Tonal palette that represents secondary. + * @param tertiaryPalette Tonal palette that represents tertiary. + * @param neutralPalette Tonal palette that represents neutral. + * @param neutralVariantPalette Tonal palette that represents neutral variant. + * @param isDark Boolean to represent if the scheme is for a dark or light theme. + * @returns Map of system variables names and their high contrast values. + */ +function getHighContrastOverides( + primaryPalette: TonalPalette, + secondaryPalette: TonalPalette, + tertiaryPalette: TonalPalette, + neutralPalette: TonalPalette, + neutralVariantPalette: TonalPalette, + isDark: boolean, +): Map { + const scheme = getMaterialDynamicScheme( + primaryPalette, + secondaryPalette, + tertiaryPalette, + neutralPalette, + neutralVariantPalette, + isDark, + 1.0, // 1.0 is the maximum contrast level + ); + + const overrides = new Map(); + + // Set system variables with values from primary palette + overrides.set('primary', hexFromArgb(scheme.primary)); + overrides.set('on-primary', hexFromArgb(scheme.onPrimary)); + overrides.set('primary-container', hexFromArgb(scheme.primaryContainer)); + overrides.set('on-primary-container', hexFromArgb(scheme.onPrimaryContainer)); + overrides.set('inverse-primary', hexFromArgb(scheme.inversePrimary)); + overrides.set('primary-fixed', hexFromArgb(scheme.primaryFixed)); + overrides.set('primary-fixed-dim', hexFromArgb(scheme.primaryFixedDim)); + overrides.set('on-primary-fixed', hexFromArgb(scheme.onPrimaryFixed)); + overrides.set('on-primary-fixed-variant', hexFromArgb(scheme.onPrimaryFixedVariant)); + + // Set system variables with values from secondary palette + overrides.set('secondary', hexFromArgb(scheme.secondary)); + overrides.set('on-secondary', hexFromArgb(scheme.onSecondary)); + overrides.set('secondary-container', hexFromArgb(scheme.secondaryContainer)); + overrides.set('on-secondary-container', hexFromArgb(scheme.onSecondaryContainer)); + overrides.set('secondary-fixed', hexFromArgb(scheme.secondaryFixed)); + overrides.set('secondary-fixed-dim', hexFromArgb(scheme.secondaryFixedDim)); + overrides.set('on-secondary-fixed', hexFromArgb(scheme.onSecondaryFixed)); + overrides.set('on-secondary-fixed-variant', hexFromArgb(scheme.onSecondaryFixedVariant)); + + // Set system variables with values from tertiary palette + overrides.set('tertiary', hexFromArgb(scheme.tertiary)); + overrides.set('on-tertiary', hexFromArgb(scheme.onTertiary)); + overrides.set('tertiary-container', hexFromArgb(scheme.tertiaryContainer)); + overrides.set('on-tertiary-container', hexFromArgb(scheme.onTertiaryContainer)); + overrides.set('tertiary-fixed', hexFromArgb(scheme.tertiaryFixed)); + overrides.set('tertiary-fixed-dim', hexFromArgb(scheme.tertiaryFixedDim)); + overrides.set('on-tertiary-fixed', hexFromArgb(scheme.onTertiaryFixed)); + overrides.set('on-tertiary-fixed-variant', hexFromArgb(scheme.onTertiaryFixedVariant)); + + // Set system variables with values from neutral palette + overrides.set('background', hexFromArgb(scheme.background)); + overrides.set('on-background', hexFromArgb(scheme.onBackground)); + overrides.set('surface', hexFromArgb(scheme.surface)); + overrides.set('surface-dim', hexFromArgb(scheme.surfaceDim)); + overrides.set('surface-bright', hexFromArgb(scheme.surfaceBright)); + overrides.set('surface-container-lowest', hexFromArgb(scheme.surfaceContainerLowest)); + overrides.set('surface-container-lowest', hexFromArgb(scheme.surfaceContainerLow)); + overrides.set('surface-container', hexFromArgb(scheme.surfaceContainer)); + overrides.set('surface-container-high', hexFromArgb(scheme.surfaceContainerHigh)); + overrides.set('surface-container-highest', hexFromArgb(scheme.surfaceContainerHighest)); + overrides.set('on-surface', hexFromArgb(scheme.onSurface)); + overrides.set('shadow', hexFromArgb(scheme.shadow)); + overrides.set('scrim', hexFromArgb(scheme.scrim)); + overrides.set('surface-tint', hexFromArgb(scheme.surfaceTint)); + overrides.set('inverse-surface', hexFromArgb(scheme.inverseSurface)); + overrides.set('inverse-on-surface', hexFromArgb(scheme.inverseOnSurface)); + overrides.set('outline', hexFromArgb(scheme.outline)); + overrides.set('outline-variant', hexFromArgb(scheme.outlineVariant)); + + // Set system variables with values from error palette + overrides.set('error', hexFromArgb(scheme.error)); + overrides.set('on-error', hexFromArgb(scheme.onError)); + overrides.set('error-container', hexFromArgb(scheme.errorContainer)); + overrides.set('on-error-container', hexFromArgb(scheme.onErrorContainer)); + + // Set system variables with values from neutral variant palette + overrides.set('surface-variant', hexFromArgb(scheme.surfaceVariant)); + overrides.set('on-surface-variant', hexFromArgb(scheme.onSurfaceVariant)); + + return overrides; +} + +/** + * Gets the scss representation of the high contrast override mixins. + * @param primaryPalette Tonal palette that represents primary. + * @param secondaryPalette Tonal palette that represents secondary. + * @param tertiaryPalette Tonal palette that represents tertiary. + * @param neutralPalette Tonal palette that represents neutral. + * @param neutralVariantPalette Tonal palette that represents neutral variant. + * @returns String of the generated high contrast mixins scss. + */ +function generateHighContrastOverrideMixinsSCSS( + primaryPalette: TonalPalette, + secondaryPalette: TonalPalette, + tertiaryPalette: TonalPalette, + neutralPalette: TonalPalette, + neutralVariantPalette: TonalPalette, +): string { + let scss = '\n'; + for (const themeType of ['light', 'dark']) { + const overrides = getHighContrastOverides( + primaryPalette, + secondaryPalette, + tertiaryPalette, + neutralPalette, + neutralVariantPalette, + themeType === 'dark', + ); + scss += '\n@mixin high-contrast-' + themeType + '-theme-overrides {\n'; + for (const [key, value] of overrides!.entries()) { + scss += ' --mat-app-' + key + ': ' + value + ';\n'; + } + scss += '};\n'; + } + return scss; +} + +/** + * Gets Hct representation of Hex color. + * @param color Hex color. + * @returns Hct color. + */ +function getHctFromHex(color: string): Hct { + try { + return Hct.fromInt(argbFromHex(color)); + } catch (e) { + throw new Error( + 'Cannot parse the specified color ' + + color + + '. Please verify it is a hex color (ex. #ffffff or ffffff).', + ); + } +} + +/** + * Creates theme file for provided scss. + * @param scss scss for the theme file. + * @param tree Directory tree. + * @param directory Directory path to place generated theme file. + */ +function createThemeFile(scss: string, tree: Tree, directory?: string) { + const filePath = directory ? directory + '_theme-colors.scss' : '_theme-colors.scss'; + tree.create(filePath, scss); +} + +export default function (options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + let colorComment = 'Color palettes are generated from primary: ' + options.primaryColor; + + // Create tonal palettes for each color and custom color overrides if applicable. Used for both + // standard contrast and high contrast schemes since they share the same tonal palettes. + // The math to generate the palettes follows how palettes are generated for SchemeFidelity + // (https://github.com/material-foundation/material-color-utilities/blob/main/typescript/scheme/scheme_fidelity.ts). + // Cannot create object directly since we allow users to enter custom colors for palettes and + // palettes are readonly for a DynamicScheme. + const primaryColorHct = getHctFromHex(options.primaryColor); + const primaryPalette = TonalPalette.fromHct(primaryColorHct); + + let secondaryPalette; + if (options.secondaryColor) { + colorComment += ', secondary: ' + options.secondaryColor; + secondaryPalette = TonalPalette.fromHct(getHctFromHex(options.secondaryColor)); + } else { + secondaryPalette = TonalPalette.fromHueAndChroma( + primaryColorHct.hue, + Math.max(primaryColorHct.chroma - 32.0, primaryColorHct.chroma * 0.5), + ); + } + + let tertiaryPalette; + if (options.tertiaryColor) { + colorComment += ', tertiary: ' + options.tertiaryColor; + tertiaryPalette = TonalPalette.fromHct(getHctFromHex(options.tertiaryColor)); + } else { + tertiaryPalette = TonalPalette.fromInt( + DislikeAnalyzer.fixIfDisliked( + new TemperatureCache(primaryColorHct).analogous(3, 6)[2], + ).toInt(), + ); + } + + let neutralPalette; + if (options.neutralColor) { + colorComment += ', neutral: ' + options.neutralColor; + neutralPalette = TonalPalette.fromHct(getHctFromHex(options.neutralColor)); + } else { + neutralPalette = TonalPalette.fromHueAndChroma( + primaryColorHct.hue, + primaryColorHct.chroma / 8.0, + ); + } + + const neutralVariantPalette = TonalPalette.fromHueAndChroma( + primaryColorHct.hue, + primaryColorHct.chroma / 8.0 + 4.0, + ); + + // Create material dynamic scheme to generate the error tonal palette + const errorPalette = getMaterialDynamicScheme( + primaryPalette, + secondaryPalette, + tertiaryPalette, + neutralPalette, + neutralVariantPalette, + false, + 0, + ).errorPalette; + + // Create the generated SCSS file with the exportable palette users can use in their `theme` + // mixin call. + const colorPalettes = getMapFromColorTonalPalettes( + primaryPalette, + secondaryPalette, + tertiaryPalette, + neutralPalette, + neutralVariantPalette, + errorPalette, + ); + let themeScss = generateSCSSTheme(colorPalettes, colorComment); + + // Add high contrast overrides mixins to generated file if specified + if (options.includeHighContrast) { + themeScss += generateHighContrastOverrideMixinsSCSS( + primaryPalette, + secondaryPalette, + tertiaryPalette, + neutralPalette, + neutralVariantPalette, + ); + } + + createThemeFile(themeScss, tree, options.directory); + }; +} diff --git a/src/material/schematics/ng-generate/m3-theme/schema.d.ts b/src/material/schematics/ng-generate/theme-color/schema.d.ts similarity index 82% rename from src/material/schematics/ng-generate/m3-theme/schema.d.ts rename to src/material/schematics/ng-generate/theme-color/schema.d.ts index f7ebdc2357af..88a738475a70 100644 --- a/src/material/schematics/ng-generate/m3-theme/schema.d.ts +++ b/src/material/schematics/ng-generate/theme-color/schema.d.ts @@ -24,13 +24,9 @@ export interface Schema { */ neutralColor?: string; /** - * Type for theme (ex. 'light', 'dark', or 'both'). + * Whether to create high contrast override theme mixins. */ - themeTypes?: string; - /** - * Whether to use system-level variables in the theme. - */ - useSystemVariables?: boolean; + includeHighContrast?: boolean; /* * Workspace-relative path to a directory where the file with the custom M3 * theme will be generated. diff --git a/src/material/schematics/ng-generate/m3-theme/schema.json b/src/material/schematics/ng-generate/theme-color/schema.json similarity index 73% rename from src/material/schematics/ng-generate/m3-theme/schema.json rename to src/material/schematics/ng-generate/theme-color/schema.json index 3118570cab0a..9369044d2219 100644 --- a/src/material/schematics/ng-generate/m3-theme/schema.json +++ b/src/material/schematics/ng-generate/theme-color/schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", "$id": "SchematicsMaterialM3Theme", - "title": "Material 3 Theme Schema", + "title": "Material 3 Theme Color Schema", "type": "object", "properties": { "primaryColor": { @@ -24,22 +24,16 @@ "description": "Color for neutral color palette", "x-prompt": "What HEX color should be used represent the neutral color palette? (Leave blank to use generated colors from Material)" }, - "directory": { - "type": "string", - "description": "Workspace-relative path to a directory where generated theme file will be created", - "x-prompt": "What is the directory you want to place the generated theme file in? (Enter the relative path such as 'src/app/styles/' or leave blank to generate at your project root)" - }, - "useSystemVariables": { + "includeHighContrast": { "type": "boolean", "default": false, - "description": "Whether to use system-level variables for the theme.", - "x-prompt": "Do you want to use system-level variables in the theme? System-level variables make dynamic theming easier through CSS custom properties, but increase the bundle size." + "description": "Whether to create high contrast override theme mixins", + "x-prompt": "Do you want to generate high contrast value override mixins for your themes?. Providing a high contrast version of your theme when a user specifies helps increase the accesibility of your application." }, - "themeTypes": { + "directory": { "type": "string", - "enum": ["light", "dark", "both"], - "description": "The components to migrate.", - "x-prompt": "Choose light, dark, or both to generate the corresponding themes" + "description": "Workspace-relative path to a directory where generated theme file will be created", + "x-prompt": "What is the directory you want to place the generated theme file in? (Enter the relative path such as 'src/app/styles/' or leave blank to generate at your project root)" } } } diff --git a/tslint.json b/tslint.json index c6f8c87632c6..311a05f0e976 100644 --- a/tslint.json +++ b/tslint.json @@ -207,7 +207,7 @@ "src/cdk/schematics/ng-update/test-cases/**/*_expected_output.ts", "src/material/schematics/ng-update/test-cases/**/*_input.ts", "src/material/schematics/ng-update/test-cases/**/*_expected_output.ts", - "src/material/schematics/ng-generate/m3-theme/index.ts", + "src/material/schematics/ng-generate/theme-color/index.ts", // These CLI-generated projects are not necessarily compliant with the lint // rules and they should remain as unmodified as possible. "integration/ng-add/**/*", diff --git a/yarn.lock b/yarn.lock index 97500f4ebcc8..e02f61a7c769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2790,10 +2790,10 @@ resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.1.3.tgz#9e5d354d845add483aa1831752663d2d879ce726" integrity sha512-S6P96biJyrt/CUYSP0v4OH1U9ITzHhHCh1kn7hHOscS3S1+T/D74sCJKQ9xb/Raos2NJHqtZ8EyQVEVjOzmqbg== -"@material/material-color-utilities@^0.2.7": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@material/material-color-utilities/-/material-color-utilities-0.2.7.tgz#ff2a638d2db295a796fa02671410df4f4f97c33e" - integrity sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ== +"@material/material-color-utilities@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@material/material-color-utilities/-/material-color-utilities-0.3.0.tgz#681b0ecd0820ac012d50d371912227f6b2dc7817" + integrity sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g== "@microsoft/api-extractor-model@7.28.17": version "7.28.17"