diff --git a/src/material/BUILD.bazel b/src/material/BUILD.bazel index 4640784669a3..fb1dc2a390a8 100644 --- a/src/material/BUILD.bazel +++ b/src/material/BUILD.bazel @@ -67,6 +67,7 @@ sass_library( "//src/material/core/theming:core_all_theme", "//src/material/core/tokens:classes", "//src/material/core/tokens:system", + "//src/material/core/tokens:token_registry", "//src/material/core/typography", "//src/material/core/typography:all_typography", "//src/material/core/typography:utils", diff --git a/src/material/_index.scss b/src/material/_index.scss index dfdf3738c594..efdf17226c0b 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -22,6 +22,7 @@ system-level-typography, system-level-elevation, system-level-shape, system-level-motion, system-level-state, theme, theme-overrides, m2-theme; @forward 'core/tokens/classes' show system-classes; +@forward 'core/tokens/token-registry' show token-var; // Private/Internal @forward './core/density/private/all-density' show all-component-densities; diff --git a/src/material/core/theming/tests/BUILD.bazel b/src/material/core/theming/tests/BUILD.bazel index 49dd08d74b0b..9df891393750 100644 --- a/src/material/core/theming/tests/BUILD.bazel +++ b/src/material/core/theming/tests/BUILD.bazel @@ -79,6 +79,7 @@ ts_project( jasmine_test( name = "unit_tests", + size = "large", data = [ ":unit_test_lib", "//src/material:sass_lib", diff --git a/src/material/core/theming/tests/token-var-api.spec.ts b/src/material/core/theming/tests/token-var-api.spec.ts new file mode 100644 index 000000000000..d8210fc2b272 --- /dev/null +++ b/src/material/core/theming/tests/token-var-api.spec.ts @@ -0,0 +1,151 @@ +import {compileString} from 'sass'; +import {runfiles} from '@bazel/runfiles'; +import * as path from 'path'; +import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer.js'; + +const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests'); +const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); +const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); + +function transpile(content: string): string { + return compileString(`@use '../../../index' as mat;\n${content}`, { + loadPaths: [testDir], + importers: [localPackageSassImporter], + }).css.toString(); +} + +// Imports the registry module directly (bypasses the public-API `show` filter) +// so tests can call registry-keys() without it being part of the public API. +function transpileRegistry(content: string): string { + return compileString(`@use '../../../core/tokens/token-registry';\n${content}`, { + loadPaths: [testDir], + importers: [localPackageSassImporter], + }).css.toString(); +} + +describe('mat.token-var()', () => { + describe('valid inputs', () => { + it('should generate CSS variable without fallback', () => { + expect(transpile(`div { color: mat.token-var(snack-bar, container-color); }`)).toContain( + 'color: var(--mat-snack-bar-container-color)', + ); + }); + + it('should generate CSS variable with a fallback value', () => { + expect( + transpile(`div { color: mat.token-var(snack-bar, container-color, white); }`), + ).toContain('color: var(--mat-snack-bar-container-color, white)'); + }); + + it('should support 0 as a fallback value', () => { + // $fallback != null (not truthy) so 0 must be preserved as a valid fallback. + expect(transpile(`div { opacity: mat.token-var(snack-bar, container-shape, 0); }`)).toContain( + 'var(--mat-snack-bar-container-shape, 0)', + ); + }); + + it('should support false as a fallback value', () => { + // $fallback != null (not truthy) so false must be preserved as a valid fallback. + // Note: must use a real CSS property (not a custom property) - Sass does not + // evaluate function calls inside custom property values (e.g. --x: ...). + expect( + transpile(`div { color: mat.token-var(snack-bar, container-shape, false); }`), + ).toContain('var(--mat-snack-bar-container-shape, false)'); + }); + + it('should work for a different component (button)', () => { + // After get-overrides strips the `button-` prefix, `button-filled-container-color` + // becomes `filled-container-color` as the token name. + expect( + transpile(`div { background: mat.token-var(button, filled-container-color); }`), + ).toContain('background: var(--mat-button-filled-container-color)'); + }); + }); + + describe('invalid inputs', () => { + it('should throw for an unknown component name', () => { + expect(() => + transpile(`div { color: mat.token-var(snackbar, container-color); }`), + ).toThrowError(/Unknown component `snackbar`/); + }); + + it('should throw for an unknown token on a valid component', () => { + expect(() => transpile(`div { color: mat.token-var(snack-bar, typo-color); }`)).toThrowError( + /Unknown token `typo-color` for component `snack-bar`/, + ); + }); + }); + + // Smoke test: verify every expected component has a registry entry. + // Uses one Sass compilation (via registry-keys()) instead of 41 separate ones + // to keep the test suite within the default Bazel timeout. + describe('registry completeness', () => { + const components = [ + 'app', + 'autocomplete', + 'badge', + 'bottom-sheet', + 'button', + 'button-toggle', + 'card', + 'checkbox', + 'chip', + 'datepicker', + 'dialog', + 'divider', + 'expansion', + 'fab', + 'form-field', + 'grid-list', + 'icon', + 'icon-button', + 'list', + 'menu', + 'optgroup', + 'option', + 'paginator', + 'progress-bar', + 'progress-spinner', + 'pseudo-checkbox', + 'radio', + 'ripple', + 'select', + 'sidenav', + 'slide-toggle', + 'slider', + 'snack-bar', + 'sort', + 'stepper', + 'table', + 'tabs', + 'timepicker', + 'toolbar', + 'tooltip', + 'tree', + ]; + + // One compilation shared by both tests below: generates a `--registered-{name}: 1` + // marker property for every component in the registry. + let registeredCss: string; + beforeAll(() => { + registeredCss = transpileRegistry( + ':root { @each $c in token-registry.registry-keys() { --registered-#{$c}: 1; } }', + ); + }); + + it('should not include input (it delegates all theming to form-field)', () => { + expect(registeredCss).not.toContain('--registered-input: 1'); + }); + + it('should have registry entries for all expected components', () => { + // A missing component produces no `--registered-: 1` property, + // failing the expect below with a clear context message. + const css = registeredCss; + for (const component of components) { + expect(css) + .withContext(`"${component}" is missing from the token registry`) + .toContain(`--registered-${component}: 1`); + } + }); + }); +}); diff --git a/src/material/core/tokens/BUILD.bazel b/src/material/core/tokens/BUILD.bazel index 9c454a93e3a9..f5696ce6a59d 100644 --- a/src/material/core/tokens/BUILD.bazel +++ b/src/material/core/tokens/BUILD.bazel @@ -92,3 +92,14 @@ sass_library( srcs = ["_classes.scss"], deps = ["//src/material/core/theming:typography"], ) + +sass_library( + name = "token_registry", + srcs = ["_token-registry.scss"], + deps = [ + # Individual component :m3 targets are provided transitively via :m3_tokens. + # When adding a new component to _token-registry.scss, also add its :m3 dep to :m3_tokens. + ":m3_tokens", + ":token_utils", + ], +) diff --git a/src/material/core/tokens/_token-registry.scss b/src/material/core/tokens/_token-registry.scss new file mode 100644 index 000000000000..7801bbc75e47 --- /dev/null +++ b/src/material/core/tokens/_token-registry.scss @@ -0,0 +1,139 @@ +@use 'sass:map'; + +// Sorted alphabetically by component name, matching the $registry map order below. +@use '../m3-app'; +@use '../../autocomplete/m3-autocomplete'; +@use '../../badge/m3-badge'; +@use '../../bottom-sheet/m3-bottom-sheet'; +@use '../../button/m3-button'; +@use '../../button/m3-fab'; +@use '../../button/m3-icon-button'; +@use '../../button-toggle/m3-button-toggle'; +@use '../../card/m3-card'; +@use '../../checkbox/m3-checkbox'; +@use '../../chips/m3-chip'; +@use '../../datepicker/m3-datepicker'; +@use '../../dialog/m3-dialog'; +@use '../../divider/m3-divider'; +@use '../../expansion/m3-expansion'; +@use '../../form-field/m3-form-field'; +@use '../../grid-list/m3-grid-list'; +@use '../../icon/m3-icon'; +@use '../../list/m3-list'; +@use '../../menu/m3-menu'; +@use '../option/m3-optgroup'; +@use '../option/m3-option'; +@use '../../paginator/m3-paginator'; +@use '../../progress-bar/m3-progress-bar'; +@use '../../progress-spinner/m3-progress-spinner'; +@use '../selection/pseudo-checkbox/m3-pseudo-checkbox'; +@use '../../radio/m3-radio'; +@use '../ripple/m3-ripple'; +@use '../../select/m3-select'; +@use '../../sidenav/m3-sidenav'; +@use '../../slide-toggle/m3-slide-toggle'; +@use '../../slider/m3-slider'; +@use '../../snack-bar/m3-snack-bar'; +@use '../../sort/m3-sort'; +@use '../../stepper/m3-stepper'; +@use '../../table/m3-table'; +@use '../../tabs/m3-tabs'; +@use '../../timepicker/m3-timepicker'; +@use '../../toolbar/m3-toolbar'; +@use '../../tooltip/m3-tooltip'; +@use '../../tree/m3-tree'; + +@use './token-utils'; + +// Note: `input` is intentionally absent from the registry — it has no M3 tokens +// of its own and delegates all theming to `form-field`. + +// Registry maps each component namespace to its overrides map. +// The overrides map has an `all` key containing token-name → default-value entries. +// Token names have the component prefix removed +// (e.g. `container-color` not `snack-bar-container-color`, +// and `filled-container-color` not `button-filled-container-color`). +$_registry: ( + app: token-utils.get-overrides(m3-app.get-tokens(), app), + autocomplete: token-utils.get-overrides(m3-autocomplete.get-tokens(), autocomplete), + badge: token-utils.get-overrides(m3-badge.get-tokens(), badge), + bottom-sheet: token-utils.get-overrides(m3-bottom-sheet.get-tokens(), bottom-sheet), + button: token-utils.get-overrides(m3-button.get-tokens(), button), + button-toggle: token-utils.get-overrides(m3-button-toggle.get-tokens(), button-toggle), + card: token-utils.get-overrides(m3-card.get-tokens(), card), + checkbox: token-utils.get-overrides(m3-checkbox.get-tokens(), checkbox), + chip: token-utils.get-overrides(m3-chip.get-tokens(), chip), + datepicker: token-utils.get-overrides(m3-datepicker.get-tokens(), datepicker), + dialog: token-utils.get-overrides(m3-dialog.get-tokens(), dialog), + divider: token-utils.get-overrides(m3-divider.get-tokens(), divider), + expansion: token-utils.get-overrides(m3-expansion.get-tokens(), expansion), + fab: token-utils.get-overrides(m3-fab.get-tokens(), fab), + form-field: token-utils.get-overrides(m3-form-field.get-tokens(), form-field), + grid-list: token-utils.get-overrides(m3-grid-list.get-tokens(), grid-list), + icon: token-utils.get-overrides(m3-icon.get-tokens(), icon), + icon-button: token-utils.get-overrides(m3-icon-button.get-tokens(), icon-button), + list: token-utils.get-overrides(m3-list.get-tokens(), list), + menu: token-utils.get-overrides(m3-menu.get-tokens(), menu), + optgroup: token-utils.get-overrides(m3-optgroup.get-tokens(), optgroup), + option: token-utils.get-overrides(m3-option.get-tokens(), option), + paginator: token-utils.get-overrides(m3-paginator.get-tokens(), paginator), + progress-bar: token-utils.get-overrides(m3-progress-bar.get-tokens(), progress-bar), + progress-spinner: token-utils.get-overrides(m3-progress-spinner.get-tokens(), progress-spinner), + pseudo-checkbox: token-utils.get-overrides(m3-pseudo-checkbox.get-tokens(), pseudo-checkbox), + radio: token-utils.get-overrides(m3-radio.get-tokens(), radio), + ripple: token-utils.get-overrides(m3-ripple.get-tokens(), ripple), + select: token-utils.get-overrides(m3-select.get-tokens(), select), + sidenav: token-utils.get-overrides(m3-sidenav.get-tokens(), sidenav), + slide-toggle: token-utils.get-overrides(m3-slide-toggle.get-tokens(), slide-toggle), + slider: token-utils.get-overrides(m3-slider.get-tokens(), slider), + snack-bar: token-utils.get-overrides(m3-snack-bar.get-tokens(), snack-bar), + sort: token-utils.get-overrides(m3-sort.get-tokens(), sort), + stepper: token-utils.get-overrides(m3-stepper.get-tokens(), stepper), + table: token-utils.get-overrides(m3-table.get-tokens(), table), + tabs: token-utils.get-overrides(m3-tabs.get-tokens(), tabs), + timepicker: token-utils.get-overrides(m3-timepicker.get-tokens(), timepicker), + toolbar: token-utils.get-overrides(m3-toolbar.get-tokens(), toolbar), + tooltip: token-utils.get-overrides(m3-tooltip.get-tokens(), tooltip), + tree: token-utils.get-overrides(m3-tree.get-tokens(), tree), +); + +/// Returns a CSS variable reference for a Material Design token. +/// Throws a Sass compile error if the component or token name is invalid. +/// +/// Token names are the CSS variable name with the `--mat-{component}-` prefix removed. +/// Components with sub-variants retain those prefixes in the token name. For example, +/// `--mat-button-filled-container-color` → token `filled-container-color`, not +/// `container-color`. Use `mat.{component}-overrides()` documentation to discover +/// the exact token names for a given component. +/// +/// @param {String} $component - Component namespace (e.g. `snack-bar`, `button`) +/// @param {String} $token - Token name without component prefix (e.g. `container-color`, +/// `filled-container-color`) +/// @param {*} $fallback - Optional CSS fallback value +/// @return CSS var() expression +@function token-var($component, $token, $fallback: null) { + @if not map.has-key($_registry, $component) { + @error 'Unknown component `#{$component}` in mat.token-var(). ' + + 'Valid components are: #{map.keys($_registry)}'; + } + + $all: map.get(map.get($_registry, $component), all); + + @if not map.has-key($all, $token) { + @error 'Unknown token `#{$token}` for component `#{$component}` in mat.token-var(). ' + + 'Valid tokens are: #{map.keys($all)}'; + } + + @if $fallback != null { + @return var(--mat-#{$component}-#{$token}, #{$fallback}); + } + + @return var(--mat-#{$component}-#{$token}); +} + +/// Returns the list of component names registered in the token registry. +/// Not forwarded through `_index.scss` — available only to consumers that +/// directly `@use` this module, such as the token-var completeness tests. +@function registry-keys() { + @return map.keys($_registry); +}