Skip to content

Commit

Permalink
feat(material-experimental/theming): add first part of token-based th…
Browse files Browse the repository at this point in the history
…eming API (#27000)

* feat(material-experimental/theming): add first part of token-based theming API

* Set up a query parameter to use the token-based Sass API in the dev-app

* Generate all M2 tokens from a theme object

* Add mixins to emit the theme based on tokens

* Add the new mat.theme API

* fixup! Add the new mat.theme API

* tweaks based on recent feedback and discussions

* addressed more feedback

* fixup! addressed more feedback
  • Loading branch information
mmalerba committed May 31, 2023
1 parent cdf2935 commit aec23ac
Show file tree
Hide file tree
Showing 47 changed files with 647 additions and 286 deletions.
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -131,10 +131,10 @@
# Material experimental package
/src/material-experimental/* @andrewseguin
/src/material-experimental/column-resize/** @andrewseguin
/src/material-experimental/mdc-tooltip/** @crisbeto
/src/material-experimental/menubar/** @jelbourn
/src/material-experimental/popover-edit/** @andrewseguin
/src/material-experimental/selection/** @andrewseguin
/src/material-experimental/theming/** @mmalerba

# CDK experimental package
/src/cdk-experimental/* @andrewseguin
Expand Down
13 changes: 7 additions & 6 deletions .ng-dev/commit-message.mts
Expand Up @@ -40,6 +40,10 @@ export const commitMessage: CommitMessageConfig = {
'cdk/tree',
'google-maps',
'material-experimental/column-resize',
'material-experimental/theming',
'material-experimental/menubar',
'material-experimental/popover-edit',
'material-experimental/selection',
'material/button',
'material/card',
'material/checkbox',
Expand All @@ -53,12 +57,6 @@ export const commitMessage: CommitMessageConfig = {
'material/snack-bar',
'material/table',
'material/tabs',
'material-experimental/menubar',
'material-experimental/popover-edit',
'material-experimental/selection',
'material-moment-adapter',
'material-date-fns-adapter',
'material-luxon-adapter',
'material/autocomplete',
'material/legacy-autocomplete',
'material/badge',
Expand Down Expand Up @@ -110,6 +108,9 @@ export const commitMessage: CommitMessageConfig = {
'material/legacy-tooltip',
'material/tooltip',
'material/tree',
'material-moment-adapter',
'material-date-fns-adapter',
'material-luxon-adapter',
'youtube-player',
],
};
11 changes: 11 additions & 0 deletions src/dev-app/BUILD.bazel
Expand Up @@ -137,6 +137,16 @@ sass_binary(
],
)

sass_binary(
name = "theme_token_api",
src = "theme-token-api.scss",
deps = [
"//src/material:sass_lib",
"//src/material-experimental:sass_lib",
"//src/material/core:theming_scss_lib",
],
)

# Variables that are going to be inlined into the dev app index.html.
filegroup(
name = "variables",
Expand All @@ -154,6 +164,7 @@ filegroup(
"favicon.ico",
"index.html",
":theme",
":theme_token_api",
":variables",
"//src/dev-app/icon:icon_demo_assets",
"@npm//:node_modules/moment/min/moment-with-locales.min.js",
Expand Down
16 changes: 13 additions & 3 deletions src/dev-app/dev-app.ts
Expand Up @@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Component, ViewEncapsulation} from '@angular/core';
import {RouterModule} from '@angular/router';
import {Component, inject, ViewEncapsulation} from '@angular/core';
import {ActivatedRoute, RouterModule} from '@angular/router';
import {DevAppLayout} from './dev-app/dev-app-layout';

/** Root component for the dev-app demos. */
Expand All @@ -18,4 +18,14 @@ import {DevAppLayout} from './dev-app/dev-app-layout';
standalone: true,
imports: [DevAppLayout, RouterModule],
})
export class DevApp {}
export class DevApp {
route = inject(ActivatedRoute);

constructor() {
this.route.queryParams.subscribe(q => {
(document.querySelector('#theme-styles') as any).href = q.hasOwnProperty('tokenapi')
? 'theme-token-api.css'
: 'theme.css';
});
}
}
2 changes: 1 addition & 1 deletion src/dev-app/index.html
Expand Up @@ -10,7 +10,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="theme.css" rel="stylesheet">
<link href="theme.css" rel="stylesheet" id="theme-styles">

<!-- FontAwesome for mat-icon demo. -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
Expand Down
68 changes: 68 additions & 0 deletions src/dev-app/theme-token-api.scss
@@ -0,0 +1,68 @@
@use '@angular/material' as mat;
@use '@angular/material-experimental' as matx;

dev-app {
&::before {
content: 'Using experimental theming API';
display: inline-block;
position: fixed;
z-index: 100;
bottom: 0;
left: 50%;
transform: translateX(-50%);
padding: 8px;
background: red;
color: white;
}
}

.demo-unicorn-dark-theme {
background: black;
color: white;
}

@include mat.core();

$light-theme: mat.define-light-theme((
color: (
primary: mat.define-palette(mat.$indigo-palette),
accent: mat.define-palette(mat.$pink-palette),
),
typography: mat.define-typography-config(),
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.

html {
@include matx.theme(
$tokens: mat.m2-tokens-from-theme($light-theme),
$components: (
matx.card(),
matx.checkbox(),
));
}

// Set up 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,
)
)),
));
}
4 changes: 3 additions & 1 deletion src/material-experimental/BUILD.bazel
Expand Up @@ -19,7 +19,9 @@ ts_library(

sass_library(
name = "theming_scss_lib",
srcs = MATERIAL_EXPERIMENTAL_SCSS_LIBS,
srcs = MATERIAL_EXPERIMENTAL_SCSS_LIBS + [
"//src/material-experimental/theming:theming_scss_lib",
],
)

sass_library(
Expand Down
3 changes: 3 additions & 0 deletions src/material-experimental/_index.scss
Expand Up @@ -4,4 +4,7 @@
@forward './popover-edit/popover-edit-theme' as popover-edit-* show popover-edit-color,
popover-edit-typography, popover-edit-density, popover-edit-theme;

// Token-based theming API
@forward './theming/theming' show theme, card, checkbox;

// Additional public APIs for individual components
9 changes: 9 additions & 0 deletions src/material-experimental/theming/BUILD.bazel
@@ -0,0 +1,9 @@
load("//tools:defaults.bzl", "sass_library")

package(default_visibility = ["//visibility:public"])

sass_library(
name = "theming_scss_lib",
srcs = glob(["**/_*.scss"]),
deps = ["//src/material:sass_lib"],
)
134 changes: 134 additions & 0 deletions src/material-experimental/theming/_theming.scss
@@ -0,0 +1,134 @@
@use 'sass:list';
@use 'sass:map';
@use 'sass:meta';
@use '@angular/material' as mat;

/// Whether to throw an error when a required dep is not configured. If false, the dep will be
/// automatically configured instead.
$_error-on-missing-dep: false;

/// Applies the theme for the given component configuration.
/// @param {Map} $tokens A map containing the default values to use for tokens not explicitly
/// customized in the component config object.
/// @param {List} $component The component config object to emit theme tokens for.
/// @output CSS variables representing the theme tokens for this component.
@mixin _apply-theme($tokens, $component) {
$id: map.get($component, id);
$tokens: map.deep-merge($tokens, map.get($component, customizations));

// NOTE: for now we use a hardcoded if-chain, but in the future when first-class mixins are
// supported, the configuration data will contain a reference to its own theme mixin.
@if $id == 'mat.card' {
@include mat.private-apply-card-theme-from-tokens($tokens);
}
@else if $id == 'mat.checkbox' {
@include mat.private-apply-checkbox-theme-from-tokens($tokens);
}
@else {
@error 'Unrecognized component theme: #{id}';
}
}

/// Gets the transitive closure of the given list of component configuration dependencies.
/// @param {List} $components The list of component config objects to get the transitive deps for.
/// @param {Map} $configured [()] A map of already configured component IDs. Used for recursion,
/// should not be passed when calling.
/// @return {List} The transitive closure of configs for the given $components.
// TODO(mmalerba): Currently we use the deps to determine if additional tokens, other than the
// explicitly requested ones need to be emitted, but the deps do not affect the ordering in which
// the various configs are processed. Before moving out of experimental we should think more about
// the ordering behavior we want. For the most part the order shouldn't matter, unless we have 2
// configs trying to set the same token.
@function _get-transitive-deps($components, $configured: ()) {
// Mark the given components as configured.
@each $component in $components {
$configured: map.set($configured, map.get($component, id), true);
}
$new-deps: ();

// Check each of the given components for new deps.
@each $component in $components {
// Note: Deps are specified as getter functions that return a config object rather than a direct
// config object. This allows us to only call the getter if the dep has not yet been configured.
// This can be useful if we have 2 components that want to require each other to be configured.
// Example: form-field and input. If we used direct config objects in this case, it would cause
// infinite co-recursion.
@each $dep-getter in mat.private-coerce-to-list(map.get($component, deps)) {
$dep: meta.call($dep-getter);
$dep-id: map.get($dep, id);
@if not (map.has-key($configured, $dep-id)) {
@if $_error-on-missing-dep {
@error 'Missing theme: `#{map.get($component, id)}` depends on `#{$dep-id}`.' +
' Please configure the theme for `#{$dep-id}` in your call to `mat.theme`';
}
@else {
$configured: map.set($configured, $dep-id, true);
$new-deps: list.append($new-deps, $dep);
}
}
}
}

// Append on the new deps to this list of component configurations and return.
@if list.length($new-deps) > 0 {
$components: list.join($components, _get-transitive-deps($new-deps, $configured));
}
@return $components;
}

/// Apply the themes for the given component configs with the given ste of fallback token values.
/// @param {Map} $tokens A map of fallback values to use for tokens that are not explicitly
/// customized by one of the component configs.
/// @param {List} $components The list of component configurations to emit tokens for.
/// @output CSS variables representing the theme tokens for the given component configs.
@mixin _theme($tokens, $components) {
// Call the theme mixin for each configured component.
@each $component in $components {
@include _apply-theme($tokens, $component);
}
}

/// Takes the full list of tokens and a list of components to configure, and outputs all theme
/// tokens for the configured components.
/// @param {Map} $tokens A map of all tokens for the current design system.
/// @param {List} $components The list of component configurations to emit tokens for.
/// @output CSS variables representing the theme tokens for the given component configs.
// TODO(mmalerba): Consider an alternate API where `$tokens` is not a separate argument,
// but one of the configs in the `$components` list
@mixin theme($tokens, $components) {
@include _theme($tokens, _get-transitive-deps(mat.private-coerce-to-list($components)));
}

/// Takes a list of components to configure, and outputs only the theme tokens that are explicitly
/// customized by the configurations.
/// @param {List} $components The list of component configurations to emit tokens for.
/// @output CSS variables representing the theme tokens for the given component configs.
// TODO(mmalerba): What should we call this?
// - update-theme
// - adjust-theme
// - edit-theme
// - 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: (),
);
}
1 change: 1 addition & 0 deletions src/material/BUILD.bazel
Expand Up @@ -24,6 +24,7 @@ sass_library(
srcs = [
"_index.scss",
"_theming.scss",
"_token-theming.scss",
],
deps = [
"//src/material/core:core_scss_lib",
Expand Down
3 changes: 3 additions & 0 deletions src/material/_index.scss
Expand Up @@ -14,6 +14,7 @@
legacy-typography-hierarchy;
@forward './core/typography/typography-utils' show typography-level,
font-size, line-height, font-weight, letter-spacing, font-family, font-shorthand;
@forward './core/tokens/m2' show m2-tokens-from-theme;

// Private/Internal
@forward './core/density/private/all-density' show all-component-densities;
Expand All @@ -37,6 +38,8 @@
@forward './core/style/button-common' as private-button-common-*;
// The form field density mixin needs to be exposed, because the paginator depends on it.
@forward './form-field/form-field-theme' as private-form-field-* show private-form-field-density;
@forward './token-theming' as private-apply-*;
@forward './core/style/sass-utils' as private-*;

// Structural
@forward './core/core' show core;
Expand Down
2 changes: 2 additions & 0 deletions src/material/_token-theming.scss
@@ -0,0 +1,2 @@
@forward './card/card-theme' as card-* show card-theme-from-tokens;
@forward './checkbox/checkbox-theme' as checkbox-* show checkbox-theme-from-tokens;
1 change: 0 additions & 1 deletion src/material/button/_icon-button-theme.scss
@@ -1,7 +1,6 @@
@use 'sass:map';
@use 'sass:math';
@use '@material/density/functions' as mdc-density-functions;
@use '@material/icon-button/mixins' as mdc-icon-button;
@use '@material/icon-button/icon-button-theme' as mdc-icon-button-theme;
@use '@material/theme/theme-color' as mdc-theme-color;
@use '../core/tokens/m2/mdc/icon-button' as tokens-mdc-icon-button;
Expand Down
2 changes: 1 addition & 1 deletion src/material/card/_card-theme.import.scss
@@ -1,3 +1,3 @@
@forward 'card-theme' hide color, density, theme, typography;
@forward 'card-theme' hide color, density, theme, typography, theme-from-tokens;
@forward 'card-theme' as mat-mdc-card-* hide $mat-mdc-card-mdc-card-action-icon-color,
$mat-mdc-card-mdc-card-outline-color;
11 changes: 11 additions & 0 deletions src/material/card/_card-theme.scss
@@ -1,3 +1,4 @@
@use 'sass:map';
@use '../core/theming/theming';
@use '../core/typography/typography';
@use '../core/tokens/token-utils';
Expand Down Expand Up @@ -76,3 +77,13 @@
}
}
}

@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));
}
}

0 comments on commit aec23ac

Please sign in to comment.