diff --git a/src/dev-app/checkbox/checkbox-demo.html b/src/dev-app/checkbox/checkbox-demo.html index d185850f53a0..ce19e105b890 100644 --- a/src/dev-app/checkbox/checkbox-demo.html +++ b/src/dev-app/checkbox/checkbox-demo.html @@ -279,3 +279,9 @@
No animations
+ +

+ + This checkbox has special styling when using the experimental token based theme + +

diff --git a/src/dev-app/theme-token-api.scss b/src/dev-app/theme-token-api.scss index 284b1312f249..d26eb2d68df4 100644 --- a/src/dev-app/theme-token-api.scss +++ b/src/dev-app/theme-token-api.scss @@ -1,3 +1,4 @@ +@use 'sass:map'; @use '@angular/material' as mat; @use '@angular/material-experimental' as matx; @@ -23,7 +24,7 @@ dev-app { @include mat.core(); -$light-theme: mat.define-light-theme(( +$theme: mat.define-light-theme(( color: ( primary: mat.define-palette(mat.$indigo-palette), accent: mat.define-palette(mat.$pink-palette), @@ -32,37 +33,82 @@ $light-theme: mat.define-light-theme(( density: 0, )); -$dark-theme: mat.define-dark-theme(( - color: ( - primary: mat.define-palette(mat.$blue-grey-palette), - accent: mat.define-palette(mat.$amber-palette, A200, A100, A400), - warn: mat.define-palette(mat.$deep-orange-palette), - ), - typography: mat.define-typography-config(), - density: 0, -)); - -// Set up light theme. - +// Apply all tokens (derived from `$theme`) to the `html` element. This ensures that all components +// on the page will inherit these tokens. html { @include matx.theme( - $tokens: mat.m2-tokens-from-theme($light-theme), + $tokens: mat.m2-tokens-from-theme($theme), $components: ( matx.card(), matx.checkbox(), - )); + ) + ); } -// Set up dark theme. +// Apply tokens needed for dark theme to the element with `.demo-unicorn-dark-theme`. +// This ensures that checkboxes within the element inherit the new tokens for dark theme, +// rather than the ones for light theme tokens set on `body`. Note that we're not setting *all* of +// the tokens, since many (density, typography, etc) are the same between light and dark theme. .demo-unicorn-dark-theme { - @include matx.theme( - $tokens: mat.m2-tokens-from-theme($dark-theme), - $components: ( - matx.checkbox(( - (mdc, checkbox): ( - selected-checkmark-color: red, - ) - )), - )); + @include matx.retheme(( + // TODO(mmalerba): In the future this should be configured through `matx.system-colors()` + matx.checkbox((theme-type: dark)), + matx.card((theme-type: dark)), + )); +} + +// Apply tokens related to the color palette to any element with `.mat-primary`, `.mat-accent`, or +// `.mat-warn` This ensures that checkboxes within the element inherit the new tokens for the +// appropriate palette, rather than the any color that may have been set on an element further up +// the hierarchy. Again, rather than applying *all* the tokens, we apply only the ones effected by +// the palette color. With this setup, the palette class need not go on the component itself +// (e.g. ), it can go on some ancestor element and the tokens will +// flow down. If multiple elements specify different classes, the closest one to the component will +// take precedence. +// (e.g.
I'm primary
) +.mat-primary { + @include matx.retheme(( + matx.checkbox(( + color-palette: map.get($theme, color, primary) + )), + )); +} +.mat-accent { + @include matx.retheme(( + matx.checkbox(( + color-palette: map.get($theme, color, accent) + )), + )); +} +.mat-warn { + @include matx.retheme(( + matx.checkbox(( + color-palette: map.get($theme, color, warn) + )), + )); +} + +// Apply tokens for a completely custom checkbox that appears as an unfilled red box when unchecked, +// and a filled green box when checked. +.demo-traffic-light-checkbox { + @include matx.retheme(( + matx.checkbox(( + checkmark-color: transparent, + selected-box-color: green, + selected-focus-box-color: green, + selected-hover-box-color: green, + selected-pressed-box-color: green, + selected-focus-ring-color: green, + selected-hover-ring-color: green, + selected-pressed-ring-color: green, + unselected-box-color: red, + unselected-focus-box-color: red, + unselected-hover-box-color: red, + unselected-pressed-box-color: red, + unselected-focus-ring-color: red, + unselected-hover-ring-color: red, + unselected-pressed-ring-color: red, + )) + )); } diff --git a/src/material-experimental/_index.scss b/src/material-experimental/_index.scss index fd2a45e8fc61..8ce0e1b965fc 100644 --- a/src/material-experimental/_index.scss +++ b/src/material-experimental/_index.scss @@ -5,6 +5,8 @@ popover-edit-typography, popover-edit-density, popover-edit-theme; // Token-based theming API -@forward './theming/theming' show theme, card, checkbox; +@forward './theming/theming' show theme, retheme; +@forward './theming/checkbox' show checkbox; +@forward './theming/card' show card; // Additional public APIs for individual components diff --git a/src/material-experimental/theming/README.md b/src/material-experimental/theming/README.md new file mode 100644 index 000000000000..ebe43c7a4cfd --- /dev/null +++ b/src/material-experimental/theming/README.md @@ -0,0 +1,61 @@ +This is an experimental theming API based on [design tokens](https://m3.material.io/foundations/design-tokens/how-to-use-tokens). It is currently in the prototype phase, +and still being evaluated. + +## Design tokens +- Design tokens are a set of variables that determine what components look like. They can affect things like color, typography, desnity, elevation, border radius, and more. +- Angular Material represents design tokens as CSS variables + +## M2 vs M3 tokens +- Angular Material can use tokens corresponding to either the [Material Design 2](https://m2.material.io/) or [Material Design 3](https://m3.material.io/) spec + - Token values for M2 can be obtained by: + 1. Generating them from an Angular Material theme object (e.g. one defined with `mat.define-light-theme`). To generate M2 tokens for a theme, pass it to the `mat.m2-tokens-from-theme` function. + - Token values for M3 are not yet available + +Example: +```scss +// Create an Angular Material theme. +$my-theme: mat.define-light-theme(...); + +// Create tokens for M2 from the theme. +$m2-tokens: mat.m2-tokens-from-theme($my-theme); +``` +## Component theme configuration functions +- These functions are used to specify which tokens should be applied by the theming mixins _and_ to customize the tokens used in that component to something other than the value from the token set +- So far the following component theme configuration functions have been implements: + - `matx.checkbox` configures tokens for the mat-checkbox to be applied + - `matx.card` configures tokens for the mat-card to be applied +- The returned configurations from these functions are passed to `matx.theme` or `matx.retheme` +- If no arguments are passed, the configuration instructs the mixin to just output the default value for all of the tokens needed by that component +- The functions can also accept a map of customizations as an argument. + - Each function has its own set of supported map keys that can be used to customize the value of the underlying tokens + - The map keys are a higher level API then the tokens, some of the keys may result in a single token being change, but some may change multiple tokens + - For supported map keys (TODO: have docs for these): + - See `$_customization-resolvers` [here](https://github.com/angular/components/blob/main/src/material-experimental/theming/_checkbox.scss) for `matx.checkbox` + - See `$_customization-resolvers` [here](https://github.com/angular/components/blob/main/src/material-experimental/theming/_card.scss) for `matx.card` + +## Theming mixins +- There are 2 mixins used for theming apps + - `matx.theme` is intended to apply the full theme for some components, with all tokens they need to function. + - `matx.retheme` is intended to re-apply specific tokens to change the appearance for some components by overriding the tokens applied by `matx.theme`. +- Both mixins emit *only* CSS variables representing design tokens +- Both mixins emit their tokens directly under the user specified selector. This gives the user complete control over the selector specificity. +- Using `matx.theme` + - Takes 2 arguments: + - `$tokens` The set of token defaults that will be used for any tokens not explicitly customized by the component theme config + - `$components` List of component theme configs indicating which components to emit tokens for, and optionally, customizations for some token values + - Outputs *all* tokens used by the configured components +- Using `matx.retheme` + - Takes 1 argument: + - `$components` List of component theme configs to emit customized token values for + - Outputs *only* the explicitly customized tokens, not any of the other tokens used by the component + +## Recommended theming structure +- Apply the base token values using `matx.theme` *once* +- Choose selectors with minimal specificity when applying tokens +- Prefer to rely on CSS inheritance to apply token overrides rather than specificity. + For example if checkbox tokens are set on the root element (`html`) they will be inherited down + the DOM and affect any `` within the document. If checkboxes in a specific section + need to appear differently, say within `.dark-sidebar`, set the token overrides on the + `.dark-sidebar` element and they will be inherited down to the checkboxes within, instead of the + values from the root element. +- For a small example, see this [alternate partial theme](https://github.com/angular/components/blob/main/src/dev-app/theme-token-api.scss) for the dev-app diff --git a/src/material-experimental/theming/_card.scss b/src/material-experimental/theming/_card.scss new file mode 100644 index 000000000000..7ec6148f403d --- /dev/null +++ b/src/material-experimental/theming/_card.scss @@ -0,0 +1,93 @@ +@use 'sass:color'; +@use 'sass:meta'; +@use '@angular/material' as mat; +@use './token-resolution'; + +// TODO(mmalerba): This should live under material/card when moving out of experimental. + +/// Gets tokens for setting the card's shape. +/// @param {String} $shape The card's shape. +/// @return {Map} A map of tokens for setting the card's shape. +// Note: we use a function rather than simple rename, because we want to map a single shape value to +// multiple tokens, rather than offer separate shape customizations for elevated and outlined cards. +@function _get-tokens-for-card-shape($shape) { + @return ( + (mdc, elevated-card): (container-shape: $shape), + (mdc, outline-card): (container-shape: $shape), + ); +} + +/// Gets tokens for setting the card's color. +/// @param {String} $shape The card's shape. +/// @return {Map} A map of tokens for setting the card's shape. +@function _get-tokens-for-card-color($color) { + @return ( + (mdc, elevated-card): (container-color: $color), + (mdc, outline-card): (container-color: $color), + ); +} + +/// Gets a map of card token values that are derived from the theme type. +/// @param {'light' | 'dark'} $theme-type The type of theme. +/// @return {Map} A map of card token values derived from the given theme type. +@function _get-tokens-for-theme-type($theme-type) { + $is-dark: $theme-type == 'dark'; + $foreground: if($is-dark, white, black); + $card-color: if($is-dark, mat.get-color-from-palette(mat.$gray-palette, 800), white); + $outline-color: color.change($foreground, $alpha: 0.12); + $subtitle-color: if($is-dark, rgba(white, 0.7), rgba(black, 0.54)); + + @return ( + (mdc, elevated-card): ( + container-color: $card-color, + ), + (mdc, outlined-card): ( + container-color: $card-color, + outline-color: $outline-color, + ), + (mat, card): ( + subtitle-text-color: $subtitle-color, + ), + ); +} + +/// Resolvers for mat-card customizations. +$_customization-resolvers: mat.private-merge-all( + token-resolution.alias(( + elevation: container-elevation, + shadow-color: container-shadow-color, + ), (mdc, elevated-card)), + token-resolution.forward(( + outline-width, + outline-color + ), (mdc, outlined-card)), + token-resolution.alias(( + title-font: title-text-font, + title-line-height: title-text-line-height, + title-font-size: title-text-size, + title-letter-spacing: title-text-tracking, + title-font-weight: title-text-weight, + subtitle-font: subtitle-text-font, + subtitle-line-height: subtitle-text-line-height, + subtitle-font-size: subtitle-text-size, + subtitle-letter-spacing: subtitle-text-tracking, + subtitle-font-weight: subtitle-text-weight, + subtitle-color: subtitle-text-color + ), (mat, card)), + ( + background-color: meta.get-function(_get-tokens-for-card-color), + border-radius: meta.get-function(_get-tokens-for-card-shape), + theme-type: meta.get-function(_get-tokens-for-theme-type), + ) +); + +/// Configure the mat-card's theme. +/// @param {Map} $customizations [()] A map of custom values to use when theming mat-card. +@function card($customizations: ()) { + @return ( + id: 'mat.card', + customizations: token-resolution.resolve-customized-tokens( + 'mat.card', $_customization-resolvers, $customizations), + deps: (), + ); +} diff --git a/src/material-experimental/theming/_checkbox.scss b/src/material-experimental/theming/_checkbox.scss new file mode 100644 index 000000000000..b89b2c8e9907 --- /dev/null +++ b/src/material-experimental/theming/_checkbox.scss @@ -0,0 +1,109 @@ +@use 'sass:color'; +@use 'sass:map'; +@use 'sass:meta'; +@use '@angular/material' as mat; +@use '@material/theme/theme-color' as mdc-theme-color; +@use './token-resolution'; + +// TODO(mmalerba): This should live under material/checkbox when moving out of experimental. + +// Duplicated from core/tokens/m2/mdc/checkbox +// TODO(mmalerba): Delete duplicated code when this is moved out of experimental. +@function _contrast-tone($value, $light-color: '#fff', $dark-color: '#000') { + @if ($value == 'dark' or $value == 'light' or type-of($value) == 'color') { + @return if(mdc-theme-color.contrast-tone($value) == 'dark', $dark-color, $light-color); + } + @return false; +} + +/// Gets a map of checkbox token values that are derived from the given palette. +/// @param {Map} $palette An Angular Material palette object. +/// @return {Map} A map of checkbox token values derived from the given palette. +@function _get-tokens-for-color-palette($palette) { + $palette-default-color: mat.get-color-from-palette($palette); + $checkmark-color: _contrast-tone($palette-default-color); + + @return ( + (mdc, checkbox): ( + selected-checkmark-color: $checkmark-color, + selected-focus-icon-color: $palette-default-color, + selected-hover-icon-color: $palette-default-color, + selected-icon-color: $palette-default-color, + selected-pressed-icon-color: $palette-default-color, + selected-focus-state-layer-color: $palette-default-color, + selected-hover-state-layer-color: $palette-default-color, + selected-pressed-state-layer-color: $palette-default-color, + ) + ); +} + +/// Gets a map of checkbox token values that are derived from the theme type. +/// @param {'light' | 'dark'} $theme-type The type of theme. +/// @return {Map} A map of checkbox token values derived from the given theme type. +@function _get-tokens-for-theme-type($theme-type) { + $is-dark: $theme-type == dark; + $foreground: if($is-dark, white, black); + $disabled-color: color.change($foreground, $alpha: 0.38); + $border-color: color.change($foreground, $alpha: 0.54); + $active-border-color: mat.get-color-from-palette(mat.$gray-palette, if($is-dark, 200, 900)); + + @return ( + (mdc, checkbox): ( + disabled-selected-icon-color: $disabled-color, + disabled-unselected-icon-color: $disabled-color, + unselected-focus-icon-color: $active-border-color, + unselected-hover-icon-color: $active-border-color, + unselected-icon-color: $border-color, + unselected-pressed-icon-color: $border-color, + unselected-focus-state-layer-color: $foreground, + unselected-hover-state-layer-color: $foreground, + unselected-pressed-state-layer-color: $foreground, + ) + ); +} + +/// Resolvers for mat-checkbox customizations. +$_customization-resolvers: map.merge( + token-resolution.alias(( + checkmark-color: selected-checkmark-color, + disabled-checkmark-color: disabled-selected-checkmark-color, + selected-focus-ring-opacity: selected-focus-state-layer-opacity, + selected-hover-ring-opacity: selected-hover-state-layer-opacity, + selected-pressed-ring-opacity: selected-pressed-state-layer-opacity, + unselected-focus-ring-opacity: unselected-focus-state-layer-opacity, + unselected-hover-ring-opacity: unselected-hover-state-layer-opacity, + unselected-pressed-ring-opacity: unselected-pressed-state-layer-opacity, + disabled-selected-box-color: disabled-selected-icon-color, + disabled-unselected-box-color: disabled-unselected-icon-color, + selected-focus-box-color: selected-focus-icon-color, + selected-hover-box-color: selected-hover-icon-color, + selected-box-color: selected-icon-color, + selected-pressed-box-color: selected-pressed-icon-color, + unselected-focus-box-color: unselected-focus-icon-color, + unselected-hover-box-color: unselected-hover-icon-color, + unselected-box-color: unselected-icon-color, + unselected-pressed-box-color: unselected-pressed-icon-color, + selected-focus-ring-color: selected-focus-state-layer-color, + selected-hover-ring-color: selected-hover-state-layer-color, + selected-pressed-ring-color: selected-pressed-state-layer-color, + unselected-focus-ring-color: unselected-focus-state-layer-color, + unselected-hover-ring-color: unselected-hover-state-layer-color, + unselected-pressed-ring-color: unselected-pressed-state-layer-color, + ripple-size: state-layer-size, + ), (mdc, checkbox)), + ( + color-palette: meta.get-function(_get-tokens-for-color-palette), + theme-type: meta.get-function(_get-tokens-for-theme-type), + ) +); + +/// Configure the mat-checkbox's theme. +/// @param {Map} $customizations [()] A map of custom values to use when theming mat-checkbox. +@function checkbox($customizations: ()) { + @return ( + id: 'mat.checkbox', + customizations: token-resolution.resolve-customized-tokens( + 'mat.checkbox', $_customization-resolvers, $customizations), + deps: (), + ); +} diff --git a/src/material-experimental/theming/_theming.scss b/src/material-experimental/theming/_theming.scss index 9a6b181d8a8a..1c4007da0bb1 100644 --- a/src/material-experimental/theming/_theming.scss +++ b/src/material-experimental/theming/_theming.scss @@ -110,25 +110,5 @@ $_error-on-missing-dep: false; // - override-theme // - retheme @mixin retheme($components) { - @include _theme((), $components); -} - -/// Configure the mat-card's theme. -/// @param {Map} $customizations [()] A map of custom token values to use when theming mat-card. -@function card($customizations: ()) { - @return ( - id: 'mat.card', - customizations: $customizations, - deps: (), - ); -} - -/// Configure the mat-checkbox's theme. -/// @param {Map} $customizations [()] A map of custom token values to use when theming mat-checkbox. -@function checkbox($customizations: ()) { - @return ( - id: 'mat.checkbox', - customizations: $customizations, - deps: (), - ); + @include _theme((), mat.private-coerce-to-list($components)); } diff --git a/src/material-experimental/theming/_token-resolution.scss b/src/material-experimental/theming/_token-resolution.scss new file mode 100644 index 000000000000..d3edcce7d729 --- /dev/null +++ b/src/material-experimental/theming/_token-resolution.scss @@ -0,0 +1,88 @@ +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:meta'; + +/// Creates a map of short token names to fully qualified token name under the given namespace. +/// @param {List} $tokens A list of tokens to forward under the given namespace. +/// @param {List} $namespace The namespace to use for the forwarded tokens. +/// @return {Map} A map of the short token name to pairs of (namespace, token-name) representing the +/// fully-qualified name +/// @example +/// forward((token1, token2), (mat, my-comp)) +/// => ( +/// token1: ((mat, my-comp), token1), +/// token2: ((mat, my-comp), token1) +/// ) +@function forward($tokens, $namespace) { + $result: (); + @each $token in $tokens { + $result: map.set($result, $token, ($namespace, $token)); + } + @return $result; +} + +// Creates a map of token alias names to fully qualified canonical names under the given namespace. +/// @param {Map} $tokens A map of aliases to canonical short names for tokens under the given +/// namespace. +/// @param {List} $namespace The namespace to use for the canonical tokens. +/// @return A map of the token alias name to pairs of (namespace, token-name) representing the +/// fully-qualified canonical name of the token. +/// @example +/// alias((alias1: canonical1, alias2: canonical2), (mat, my-comp)) +/// => ( +/// alias1: ((mat, my-comp), canonical1), +/// alias2: ((mat, my-comp), canonical2) +/// ) +@function alias($tokens, $namespace) { + $result: (); + @each $from, $to in $tokens { + $result: map.set($result, $from, ($namespace, $to)); + } + @return $result; +} + +/// Gets the full set of customized tokens from a component configuration's customization map. +/// @param {String} $component-id The id of the component whose customizations are being resolved. +/// Used for error logging purposes. +/// @param {Map} $customization-resolvers A map of resolvers that map customization keys to +/// fully-qualified token names or functions to generate fully-qualified token names. +/// @param {Map} $customizations A map of values for customization keys +/// @return {Map} A map of fully-qualified token values +/// @example +/// resolve-customized-tokens('mat.checkbox', +/// forward(my-color, my-size, (mat, my-comp)), +/// (my-color: red, my-size: 100px) +/// ) +/// => ( +/// (mat, my-comp): ( +/// my-color: red, +/// my-size: 100px +/// ) +/// ) +@function resolve-customized-tokens($component-id, $customization-resolvers, $customizations) { + $result: (); + + @each $customization, $value in $customizations { + $resolver: map.get($customization-resolvers, $customization); + @if not $resolver { + @error 'Unrecognized customization for #{$component-id}: #{$customization}'; + } + + $resolver-type: meta.type-of($resolver); + @if $resolver-type == 'list' { + // If the resolver is a list, it represents the token namespace and name. + $key-and-value: list.append($resolver, $value); + $result: map.deep-merge($result, map.set((), $key-and-value...)); + } + @else if $resolver-type == 'function' { + // If the resolver is a function, it should take a value and return a token map. + $result: map.deep-merge($result, meta.call($resolver, $value)); + } + @else { + // Anything else is unexpected. + @error 'Invalid customization resolver for `#{$customization}` on #{$component-id}'; + } + } + + @return $result; +} diff --git a/src/material/card/_card-theme.scss b/src/material/card/_card-theme.scss index 2f3eb3cbb71a..bef100328208 100644 --- a/src/material/card/_card-theme.scss +++ b/src/material/card/_card-theme.scss @@ -79,11 +79,8 @@ } @mixin theme-from-tokens($tokens) { - // Add values for card tokens. - .mat-mdc-card { - @include mdc-elevated-card-theme.theme(map.get($tokens, tokens-mdc-elevated-card.$prefix)); - @include mdc-outlined-card-theme.theme(map.get($tokens, tokens-mdc-outlined-card.$prefix)); - @include token-utils.create-token-values( - tokens-mat-card.$prefix, map.get($tokens, tokens-mat-card.$prefix)); - } + @include mdc-elevated-card-theme.theme(map.get($tokens, tokens-mdc-elevated-card.$prefix)); + @include mdc-outlined-card-theme.theme(map.get($tokens, tokens-mdc-outlined-card.$prefix)); + @include token-utils.create-token-values( + tokens-mat-card.$prefix, map.get($tokens, tokens-mat-card.$prefix)); } diff --git a/src/material/checkbox/_checkbox-theme.scss b/src/material/checkbox/_checkbox-theme.scss index b46761feeac5..b18b0b323dcc 100644 --- a/src/material/checkbox/_checkbox-theme.scss +++ b/src/material/checkbox/_checkbox-theme.scss @@ -92,8 +92,5 @@ @mixin theme-from-tokens($tokens) { // TODO(mmalerba): Some of the theme styles above are not represented in terms of tokens, // so this mixin is currently incomplete. - - .mat-mdc-checkbox { - @include mdc-checkbox-theme.theme(map.get($tokens, tokens-mdc-checkbox.$prefix)); - } + @include mdc-checkbox-theme.theme(map.get($tokens, tokens-mdc-checkbox.$prefix)); } diff --git a/src/material/core/style/_sass-utils.scss b/src/material/core/style/_sass-utils.scss index af9fb2218cef..772cfbec7b35 100644 --- a/src/material/core/style/_sass-utils.scss +++ b/src/material/core/style/_sass-utils.scss @@ -14,6 +14,18 @@ } } +/// A version of the standard `map.merge` function that takes a variable number of arguments. +/// Each argument is merged into the final result from left to right. +/// @param {List} $maps The maps to combine with map.merge +/// @return {Map} The combined result of successively calling map.merge with each parameter. +@function merge-all($maps...) { + $result: (); + @each $map in $maps { + $result: map.merge($result, $map); + } + @return $result; +} + /// A version of the standard `map.deep-merge` function that takes a variable number of arguments. /// Each argument is deep-merged into the final result from left to right. /// @param {List} $maps The maps to combine with map.deep-merge