Skip to content

Commit df6a1df

Browse files
committed
feat(material/theming): add type-safe mat.token-var() for CSS custom properties
Developers can now write `mat.token-var(snack-bar, container-color)` to get `var(--mat-snack-bar-container-color)` with compile-time validation of both the component name and token name. A new `_token-registry.scss` imports all M3 component token files and exposes a `token-var($component, $token, $fallback?)` function via the public `mat.*` API. Throws a Sass compile error for unknown component or token names. close #32800 Signed-off-by: Ruslan Lekhman <lekhman112@gmail.com>
1 parent 754b682 commit df6a1df

File tree

6 files changed

+304
-0
lines changed

6 files changed

+304
-0
lines changed

src/material/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ sass_library(
6767
"//src/material/core/theming:core_all_theme",
6868
"//src/material/core/tokens:classes",
6969
"//src/material/core/tokens:system",
70+
"//src/material/core/tokens:token_registry",
7071
"//src/material/core/typography",
7172
"//src/material/core/typography:all_typography",
7273
"//src/material/core/typography:utils",

src/material/_index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
system-level-typography, system-level-elevation, system-level-shape,
2323
system-level-motion, system-level-state, theme, theme-overrides, m2-theme;
2424
@forward 'core/tokens/classes' show system-classes;
25+
@forward 'core/tokens/token-registry' show token-var;
2526

2627
// Private/Internal
2728
@forward './core/density/private/all-density' show all-component-densities;

src/material/core/theming/tests/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ ts_project(
7979

8080
jasmine_test(
8181
name = "unit_tests",
82+
size = "large",
8283
data = [
8384
":unit_test_lib",
8485
"//src/material:sass_lib",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import {compileString} from 'sass';
2+
import {runfiles} from '@bazel/runfiles';
3+
import * as path from 'path';
4+
import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer.js';
5+
6+
const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests');
7+
const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..');
8+
const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir);
9+
10+
function transpile(content: string): string {
11+
return compileString(`@use '../../../index' as mat;\n${content}`, {
12+
loadPaths: [testDir],
13+
importers: [localPackageSassImporter],
14+
}).css.toString();
15+
}
16+
17+
// Imports the registry module directly (bypasses the public-API `show` filter)
18+
// so tests can call registry-keys() without it being part of the public API.
19+
function transpileRegistry(content: string): string {
20+
return compileString(`@use '../../../core/tokens/token-registry';\n${content}`, {
21+
loadPaths: [testDir],
22+
importers: [localPackageSassImporter],
23+
}).css.toString();
24+
}
25+
26+
describe('mat.token-var()', () => {
27+
describe('valid inputs', () => {
28+
it('should generate CSS variable without fallback', () => {
29+
expect(transpile(`div { color: mat.token-var(snack-bar, container-color); }`)).toContain(
30+
'color: var(--mat-snack-bar-container-color)',
31+
);
32+
});
33+
34+
it('should generate CSS variable with a fallback value', () => {
35+
expect(
36+
transpile(`div { color: mat.token-var(snack-bar, container-color, white); }`),
37+
).toContain('color: var(--mat-snack-bar-container-color, white)');
38+
});
39+
40+
it('should support 0 as a fallback value', () => {
41+
// $fallback != null (not truthy) so 0 must be preserved as a valid fallback.
42+
expect(transpile(`div { opacity: mat.token-var(snack-bar, container-shape, 0); }`)).toContain(
43+
'var(--mat-snack-bar-container-shape, 0)',
44+
);
45+
});
46+
47+
it('should support false as a fallback value', () => {
48+
// $fallback != null (not truthy) so false must be preserved as a valid fallback.
49+
// Note: must use a real CSS property (not a custom property) - Sass does not
50+
// evaluate function calls inside custom property values (e.g. --x: ...).
51+
expect(
52+
transpile(`div { color: mat.token-var(snack-bar, container-shape, false); }`),
53+
).toContain('var(--mat-snack-bar-container-shape, false)');
54+
});
55+
56+
it('should work for a different component (button)', () => {
57+
// After get-overrides strips the `button-` prefix, `button-filled-container-color`
58+
// becomes `filled-container-color` as the token name.
59+
expect(
60+
transpile(`div { background: mat.token-var(button, filled-container-color); }`),
61+
).toContain('background: var(--mat-button-filled-container-color)');
62+
});
63+
});
64+
65+
describe('invalid inputs', () => {
66+
it('should throw for an unknown component name', () => {
67+
expect(() =>
68+
transpile(`div { color: mat.token-var(snackbar, container-color); }`),
69+
).toThrowError(/Unknown component `snackbar`/);
70+
});
71+
72+
it('should throw for an unknown token on a valid component', () => {
73+
expect(() => transpile(`div { color: mat.token-var(snack-bar, typo-color); }`)).toThrowError(
74+
/Unknown token `typo-color` for component `snack-bar`/,
75+
);
76+
});
77+
});
78+
79+
// Smoke test: verify every expected component has a registry entry.
80+
// Uses one Sass compilation (via registry-keys()) instead of 41 separate ones
81+
// to keep the test suite within the default Bazel timeout.
82+
describe('registry completeness', () => {
83+
const components = [
84+
'app',
85+
'autocomplete',
86+
'badge',
87+
'bottom-sheet',
88+
'button',
89+
'button-toggle',
90+
'card',
91+
'checkbox',
92+
'chip',
93+
'datepicker',
94+
'dialog',
95+
'divider',
96+
'expansion',
97+
'fab',
98+
'form-field',
99+
'grid-list',
100+
'icon',
101+
'icon-button',
102+
'list',
103+
'menu',
104+
'optgroup',
105+
'option',
106+
'paginator',
107+
'progress-bar',
108+
'progress-spinner',
109+
'pseudo-checkbox',
110+
'radio',
111+
'ripple',
112+
'select',
113+
'sidenav',
114+
'slide-toggle',
115+
'slider',
116+
'snack-bar',
117+
'sort',
118+
'stepper',
119+
'table',
120+
'tabs',
121+
'timepicker',
122+
'toolbar',
123+
'tooltip',
124+
'tree',
125+
];
126+
127+
// One compilation shared by both tests below: generates a `--registered-{name}: 1`
128+
// marker property for every component in the registry.
129+
let registeredCss: string;
130+
beforeAll(() => {
131+
registeredCss = transpileRegistry(
132+
':root { @each $c in token-registry.registry-keys() { --registered-#{$c}: 1; } }',
133+
);
134+
});
135+
136+
it('should not include input (it delegates all theming to form-field)', () => {
137+
expect(registeredCss).not.toContain('--registered-input: 1');
138+
});
139+
140+
it('should have registry entries for all expected components', () => {
141+
// A missing component produces no `--registered-<name>: 1` property,
142+
// failing the expect below with a clear context message.
143+
const css = registeredCss;
144+
for (const component of components) {
145+
expect(css)
146+
.withContext(`"${component}" is missing from the token registry`)
147+
.toContain(`--registered-${component}: 1`);
148+
}
149+
});
150+
});
151+
});

src/material/core/tokens/BUILD.bazel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,14 @@ sass_library(
9292
srcs = ["_classes.scss"],
9393
deps = ["//src/material/core/theming:typography"],
9494
)
95+
96+
sass_library(
97+
name = "token_registry",
98+
srcs = ["_token-registry.scss"],
99+
deps = [
100+
# Individual component :m3 targets are provided transitively via :m3_tokens.
101+
# When adding a new component to _token-registry.scss, also add its :m3 dep to :m3_tokens.
102+
":m3_tokens",
103+
":token_utils",
104+
],
105+
)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
@use 'sass:map';
2+
3+
// Sorted alphabetically by component name, matching the $registry map order below.
4+
@use '../m3-app';
5+
@use '../../autocomplete/m3-autocomplete';
6+
@use '../../badge/m3-badge';
7+
@use '../../bottom-sheet/m3-bottom-sheet';
8+
@use '../../button/m3-button';
9+
@use '../../button/m3-fab';
10+
@use '../../button/m3-icon-button';
11+
@use '../../button-toggle/m3-button-toggle';
12+
@use '../../card/m3-card';
13+
@use '../../checkbox/m3-checkbox';
14+
@use '../../chips/m3-chip';
15+
@use '../../datepicker/m3-datepicker';
16+
@use '../../dialog/m3-dialog';
17+
@use '../../divider/m3-divider';
18+
@use '../../expansion/m3-expansion';
19+
@use '../../form-field/m3-form-field';
20+
@use '../../grid-list/m3-grid-list';
21+
@use '../../icon/m3-icon';
22+
@use '../../list/m3-list';
23+
@use '../../menu/m3-menu';
24+
@use '../option/m3-optgroup';
25+
@use '../option/m3-option';
26+
@use '../../paginator/m3-paginator';
27+
@use '../../progress-bar/m3-progress-bar';
28+
@use '../../progress-spinner/m3-progress-spinner';
29+
@use '../selection/pseudo-checkbox/m3-pseudo-checkbox';
30+
@use '../../radio/m3-radio';
31+
@use '../ripple/m3-ripple';
32+
@use '../../select/m3-select';
33+
@use '../../sidenav/m3-sidenav';
34+
@use '../../slide-toggle/m3-slide-toggle';
35+
@use '../../slider/m3-slider';
36+
@use '../../snack-bar/m3-snack-bar';
37+
@use '../../sort/m3-sort';
38+
@use '../../stepper/m3-stepper';
39+
@use '../../table/m3-table';
40+
@use '../../tabs/m3-tabs';
41+
@use '../../timepicker/m3-timepicker';
42+
@use '../../toolbar/m3-toolbar';
43+
@use '../../tooltip/m3-tooltip';
44+
@use '../../tree/m3-tree';
45+
46+
@use './token-utils';
47+
48+
// Note: `input` is intentionally absent from the registry — it has no M3 tokens
49+
// of its own and delegates all theming to `form-field`.
50+
51+
// Registry maps each component namespace to its overrides map.
52+
// The overrides map has an `all` key containing token-name → default-value entries.
53+
// Token names have the component prefix removed
54+
// (e.g. `container-color` not `snack-bar-container-color`,
55+
// and `filled-container-color` not `button-filled-container-color`).
56+
$_registry: (
57+
app: token-utils.get-overrides(m3-app.get-tokens(), app),
58+
autocomplete: token-utils.get-overrides(m3-autocomplete.get-tokens(), autocomplete),
59+
badge: token-utils.get-overrides(m3-badge.get-tokens(), badge),
60+
bottom-sheet: token-utils.get-overrides(m3-bottom-sheet.get-tokens(), bottom-sheet),
61+
button: token-utils.get-overrides(m3-button.get-tokens(), button),
62+
button-toggle: token-utils.get-overrides(m3-button-toggle.get-tokens(), button-toggle),
63+
card: token-utils.get-overrides(m3-card.get-tokens(), card),
64+
checkbox: token-utils.get-overrides(m3-checkbox.get-tokens(), checkbox),
65+
chip: token-utils.get-overrides(m3-chip.get-tokens(), chip),
66+
datepicker: token-utils.get-overrides(m3-datepicker.get-tokens(), datepicker),
67+
dialog: token-utils.get-overrides(m3-dialog.get-tokens(), dialog),
68+
divider: token-utils.get-overrides(m3-divider.get-tokens(), divider),
69+
expansion: token-utils.get-overrides(m3-expansion.get-tokens(), expansion),
70+
fab: token-utils.get-overrides(m3-fab.get-tokens(), fab),
71+
form-field: token-utils.get-overrides(m3-form-field.get-tokens(), form-field),
72+
grid-list: token-utils.get-overrides(m3-grid-list.get-tokens(), grid-list),
73+
icon: token-utils.get-overrides(m3-icon.get-tokens(), icon),
74+
icon-button: token-utils.get-overrides(m3-icon-button.get-tokens(), icon-button),
75+
list: token-utils.get-overrides(m3-list.get-tokens(), list),
76+
menu: token-utils.get-overrides(m3-menu.get-tokens(), menu),
77+
optgroup: token-utils.get-overrides(m3-optgroup.get-tokens(), optgroup),
78+
option: token-utils.get-overrides(m3-option.get-tokens(), option),
79+
paginator: token-utils.get-overrides(m3-paginator.get-tokens(), paginator),
80+
progress-bar: token-utils.get-overrides(m3-progress-bar.get-tokens(), progress-bar),
81+
progress-spinner: token-utils.get-overrides(m3-progress-spinner.get-tokens(), progress-spinner),
82+
pseudo-checkbox: token-utils.get-overrides(m3-pseudo-checkbox.get-tokens(), pseudo-checkbox),
83+
radio: token-utils.get-overrides(m3-radio.get-tokens(), radio),
84+
ripple: token-utils.get-overrides(m3-ripple.get-tokens(), ripple),
85+
select: token-utils.get-overrides(m3-select.get-tokens(), select),
86+
sidenav: token-utils.get-overrides(m3-sidenav.get-tokens(), sidenav),
87+
slide-toggle: token-utils.get-overrides(m3-slide-toggle.get-tokens(), slide-toggle),
88+
slider: token-utils.get-overrides(m3-slider.get-tokens(), slider),
89+
snack-bar: token-utils.get-overrides(m3-snack-bar.get-tokens(), snack-bar),
90+
sort: token-utils.get-overrides(m3-sort.get-tokens(), sort),
91+
stepper: token-utils.get-overrides(m3-stepper.get-tokens(), stepper),
92+
table: token-utils.get-overrides(m3-table.get-tokens(), table),
93+
tabs: token-utils.get-overrides(m3-tabs.get-tokens(), tabs),
94+
timepicker: token-utils.get-overrides(m3-timepicker.get-tokens(), timepicker),
95+
toolbar: token-utils.get-overrides(m3-toolbar.get-tokens(), toolbar),
96+
tooltip: token-utils.get-overrides(m3-tooltip.get-tokens(), tooltip),
97+
tree: token-utils.get-overrides(m3-tree.get-tokens(), tree),
98+
);
99+
100+
/// Returns a CSS variable reference for a Material Design token.
101+
/// Throws a Sass compile error if the component or token name is invalid.
102+
///
103+
/// Token names are the CSS variable name with the `--mat-{component}-` prefix removed.
104+
/// Components with sub-variants retain those prefixes in the token name. For example,
105+
/// `--mat-button-filled-container-color` → token `filled-container-color`, not
106+
/// `container-color`. Use `mat.{component}-overrides()` documentation to discover
107+
/// the exact token names for a given component.
108+
///
109+
/// @param {String} $component - Component namespace (e.g. `snack-bar`, `button`)
110+
/// @param {String} $token - Token name without component prefix (e.g. `container-color`,
111+
/// `filled-container-color`)
112+
/// @param {*} $fallback - Optional CSS fallback value
113+
/// @return CSS var() expression
114+
@function token-var($component, $token, $fallback: null) {
115+
@if not map.has-key($_registry, $component) {
116+
@error 'Unknown component `#{$component}` in mat.token-var(). ' +
117+
'Valid components are: #{map.keys($_registry)}';
118+
}
119+
120+
$all: map.get(map.get($_registry, $component), all);
121+
122+
@if not map.has-key($all, $token) {
123+
@error 'Unknown token `#{$token}` for component `#{$component}` in mat.token-var(). ' +
124+
'Valid tokens are: #{map.keys($all)}';
125+
}
126+
127+
@if $fallback != null {
128+
@return var(--mat-#{$component}-#{$token}, #{$fallback});
129+
}
130+
131+
@return var(--mat-#{$component}-#{$token});
132+
}
133+
134+
/// Returns the list of component names registered in the token registry.
135+
/// Not forwarded through `_index.scss` — available only to consumers that
136+
/// directly `@use` this module, such as the token-var completeness tests.
137+
@function registry-keys() {
138+
@return map.keys($_registry);
139+
}

0 commit comments

Comments
 (0)