Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/material/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/material/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/material/core/theming/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ ts_project(

jasmine_test(
name = "unit_tests",
size = "large",
data = [
":unit_test_lib",
"//src/material:sass_lib",
Expand Down
151 changes: 151 additions & 0 deletions src/material/core/theming/tests/token-var-api.spec.ts
Original file line number Diff line number Diff line change
@@ -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-<name>: 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`);
}
});
});
});
11 changes: 11 additions & 0 deletions src/material/core/tokens/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
139 changes: 139 additions & 0 deletions src/material/core/tokens/_token-registry.scss
Original file line number Diff line number Diff line change
@@ -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);
}
Loading